mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: align Storage Plans with combined package model
Update ZPan to treat each Store package as one product that can include both storage quota and download traffic quota. - replace package resourceType/resourceBytes with storageBytes/trafficBytes in shared schemas and types - widen package and checkout currency handling to string - update Cloud proxy parsing for new package response shapes - redesign admin package form/list and Store package cards around combined quotas - update API and UI tests for storage-only, traffic-only, and combined package flows - remove an unreachable duplicate parser branch so Codecov patch coverage stays green Task: jrfmeahm17rp
This commit is contained in:
@@ -15,12 +15,13 @@ export const cloudCheckoutResponseSchema = z
|
||||
.transform((value) => ({ checkoutUrl: value.url }))
|
||||
export const cloudRedemptionResponseSchema = z.object({ ok: z.boolean() }).passthrough()
|
||||
const cloudPackagePriceSchema = z.union([
|
||||
z.object({ currency: z.enum(['usd', 'cny']), amount: z.number().int().positive() }),
|
||||
z.object({ currency: z.string().min(1), amount: z.number().int().positive() }),
|
||||
z
|
||||
.object({ currency: z.enum(['usd', 'cny']), unit_amount: z.number().int().positive() })
|
||||
.object({ currency: z.string().min(1), unit_amount: z.number().int().positive() })
|
||||
.transform((price) => ({ currency: price.currency, amount: price.unit_amount })),
|
||||
])
|
||||
const cloudPackageSchema = z.union([
|
||||
// Legacy camelCase: resourceType/resourceBytes → storageBytes/trafficBytes
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -34,7 +35,19 @@ const cloudPackageSchema = z.union([
|
||||
createdAt: z.string().min(1),
|
||||
updatedAt: z.string().min(1),
|
||||
})
|
||||
.transform((pkg) => ({ ...pkg, description: pkg.description ?? '' })),
|
||||
.transform((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description ?? '',
|
||||
storageBytes: pkg.resourceType === 'storage' ? pkg.resourceBytes : 0,
|
||||
trafficBytes: pkg.resourceType === 'traffic' ? pkg.resourceBytes : 0,
|
||||
prices: pkg.prices,
|
||||
active: pkg.active,
|
||||
sortOrder: pkg.sortOrder,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
})),
|
||||
// Legacy snake_case: resource_type/resource_bytes → storageBytes/trafficBytes
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -52,14 +65,55 @@ const cloudPackageSchema = z.union([
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description ?? '',
|
||||
resourceType: pkg.resource_type,
|
||||
resourceBytes: pkg.resource_bytes,
|
||||
storageBytes: pkg.resource_type === 'storage' ? pkg.resource_bytes : 0,
|
||||
trafficBytes: pkg.resource_type === 'traffic' ? pkg.resource_bytes : 0,
|
||||
prices: pkg.prices,
|
||||
active: pkg.active,
|
||||
sortOrder: pkg.sort_order,
|
||||
createdAt: pkg.created_at,
|
||||
updatedAt: pkg.updated_at,
|
||||
})),
|
||||
// New snake_case: storage_bytes/traffic_bytes
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().default(''),
|
||||
storage_bytes: z.number().int().min(0).default(0),
|
||||
traffic_bytes: z.number().int().min(0).default(0),
|
||||
prices: z.array(cloudPackagePriceSchema).min(1),
|
||||
active: z.boolean().default(true),
|
||||
sort_order: z.number().int().default(0),
|
||||
created_at: z.string().min(1),
|
||||
updated_at: z.string().min(1),
|
||||
})
|
||||
.transform((pkg) => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description ?? '',
|
||||
storageBytes: pkg.storage_bytes,
|
||||
trafficBytes: pkg.traffic_bytes,
|
||||
prices: pkg.prices,
|
||||
active: pkg.active,
|
||||
sortOrder: pkg.sort_order,
|
||||
createdAt: pkg.created_at,
|
||||
updatedAt: pkg.updated_at,
|
||||
})),
|
||||
// New camelCase: storageBytes/trafficBytes (must come last due to optional defaults)
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().nullable().default(''),
|
||||
storageBytes: z.number().int().min(0).default(0),
|
||||
trafficBytes: z.number().int().min(0).default(0),
|
||||
prices: z.array(cloudPackagePriceSchema).min(1),
|
||||
active: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(0),
|
||||
createdAt: z.string().min(1),
|
||||
updatedAt: z.string().min(1),
|
||||
})
|
||||
.transform((pkg) => ({ ...pkg, description: pkg.description ?? '' })),
|
||||
])
|
||||
export const cloudPackageResponseSchema = cloudPackageSchema
|
||||
export const cloudPackageListResponseSchema = z.union([
|
||||
|
||||
@@ -189,6 +189,66 @@ afterEach(() => {
|
||||
|
||||
describe('Quota Store API', () => {
|
||||
it('parses Cloud package and storage-code response shapes', () => {
|
||||
// legacy camelCase: resourceType/resourceBytes
|
||||
expect(
|
||||
cloudPackageResponseSchema.parse({
|
||||
id: 'pkg-camel-legacy',
|
||||
name: 'Camel Legacy',
|
||||
description: null,
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 1024,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'pkg-camel-legacy',
|
||||
name: 'Camel Legacy',
|
||||
description: '',
|
||||
storageBytes: 1024,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
})
|
||||
|
||||
// legacy camelCase: resourceType/resourceBytes (traffic case to cover both ternary branches)
|
||||
expect(
|
||||
cloudPackageResponseSchema.parse({
|
||||
id: 'pkg-camel-legacy-traffic',
|
||||
name: 'Camel Legacy Traffic',
|
||||
description: null,
|
||||
resourceType: 'traffic',
|
||||
resourceBytes: 512,
|
||||
prices: [{ currency: 'usd', amount: 200 }],
|
||||
active: true,
|
||||
sortOrder: 0,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
}),
|
||||
).toMatchObject({ storageBytes: 0, trafficBytes: 512 })
|
||||
|
||||
// legacy snake_case: resource_type/resource_bytes (storage case to cover both ternary branches)
|
||||
expect(
|
||||
cloudPackageResponseSchema.parse({
|
||||
id: 'pkg-snake-storage',
|
||||
name: 'Snake Storage',
|
||||
description: null,
|
||||
resource_type: 'storage',
|
||||
resource_bytes: 2048,
|
||||
prices: [{ currency: 'cny', unit_amount: 3600 }],
|
||||
active: true,
|
||||
sort_order: 4,
|
||||
created_at: '2026-05-06T00:00:00.000Z',
|
||||
updated_at: '2026-05-06T00:00:00.000Z',
|
||||
}),
|
||||
).toMatchObject({ storageBytes: 2048, trafficBytes: 0 })
|
||||
|
||||
// legacy snake_case: resource_type/resource_bytes (traffic case - original test)
|
||||
expect(
|
||||
cloudPackageResponseSchema.parse({
|
||||
id: 'pkg-snake',
|
||||
@@ -206,8 +266,8 @@ describe('Quota Store API', () => {
|
||||
id: 'pkg-snake',
|
||||
name: 'Snake Package',
|
||||
description: '',
|
||||
resourceType: 'traffic',
|
||||
resourceBytes: 2048,
|
||||
storageBytes: 0,
|
||||
trafficBytes: 2048,
|
||||
prices: [{ currency: 'cny', amount: 3600 }],
|
||||
active: true,
|
||||
sortOrder: 4,
|
||||
@@ -215,6 +275,60 @@ describe('Quota Store API', () => {
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
})
|
||||
|
||||
// new snake_case: storage_bytes/traffic_bytes
|
||||
expect(
|
||||
cloudPackageResponseSchema.parse({
|
||||
id: 'pkg-snake-new',
|
||||
name: 'Snake New',
|
||||
description: null,
|
||||
storage_bytes: 4096,
|
||||
traffic_bytes: 8192,
|
||||
prices: [{ currency: 'usd', amount: 999 }],
|
||||
active: true,
|
||||
sort_order: 2,
|
||||
created_at: '2026-05-06T00:00:00.000Z',
|
||||
updated_at: '2026-05-06T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'pkg-snake-new',
|
||||
name: 'Snake New',
|
||||
description: '',
|
||||
storageBytes: 4096,
|
||||
trafficBytes: 8192,
|
||||
prices: [{ currency: 'usd', amount: 999 }],
|
||||
active: true,
|
||||
sortOrder: 2,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
})
|
||||
|
||||
// new camelCase: storageBytes/trafficBytes
|
||||
expect(
|
||||
cloudPackageResponseSchema.parse({
|
||||
id: 'pkg-camel-new',
|
||||
name: 'Camel New',
|
||||
description: null,
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'eur', amount: 799 }],
|
||||
active: false,
|
||||
sortOrder: 5,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'pkg-camel-new',
|
||||
name: 'Camel New',
|
||||
description: '',
|
||||
storageBytes: 2048,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'eur', amount: 799 }],
|
||||
active: false,
|
||||
sortOrder: 5,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(
|
||||
cloudStorageCodesResponseSchema.parse([
|
||||
cloudStorageCode({
|
||||
@@ -292,7 +406,7 @@ describe('Quota Store API', () => {
|
||||
const res = await app.request('/api/admin/quota-store/packages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Bad', description: '', resourceType: 'storage', resourceBytes: 0, prices: [] }),
|
||||
body: JSON.stringify({ name: 'Bad', description: '', storageBytes: 0, trafficBytes: 0, prices: [] }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
@@ -366,8 +480,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Small',
|
||||
description: 'starter',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
@@ -382,8 +495,7 @@ describe('Quota Store API', () => {
|
||||
expect(JSON.parse(body)).toMatchObject({
|
||||
name: 'Small',
|
||||
description: 'starter',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
})
|
||||
expect(JSON.parse(body)).not.toHaveProperty('callbackUrl')
|
||||
@@ -433,8 +545,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Updated',
|
||||
description: '',
|
||||
resourceType: 'traffic',
|
||||
resourceBytes: 8192,
|
||||
trafficBytes: 8192,
|
||||
prices: [{ currency: 'cny', amount: 900 }],
|
||||
}),
|
||||
})
|
||||
@@ -442,7 +553,7 @@ describe('Quota Store API', () => {
|
||||
expect(listed.status).toBe(200)
|
||||
await expect(listed.json()).resolves.toMatchObject({
|
||||
total: 1,
|
||||
items: [{ id: 'cloud-pkg-object', description: '', resourceType: 'traffic', sortOrder: 3 }],
|
||||
items: [{ id: 'cloud-pkg-object', description: '', trafficBytes: 4096, storageBytes: 0, sortOrder: 3 }],
|
||||
})
|
||||
expect(updated.status).toBe(200)
|
||||
const [, updateInit] = vi.mocked(fetch).mock.calls[1] as [URL, RequestInit]
|
||||
@@ -450,8 +561,8 @@ describe('Quota Store API', () => {
|
||||
expect(JSON.parse(updateInit.body as string)).toEqual({
|
||||
name: 'Updated',
|
||||
description: '',
|
||||
resourceType: 'traffic',
|
||||
resourceBytes: 8192,
|
||||
trafficBytes: 8192,
|
||||
storageBytes: 0,
|
||||
prices: [{ currency: 'cny', amount: 900 }],
|
||||
active: true,
|
||||
sortOrder: 0,
|
||||
@@ -485,8 +596,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Configured',
|
||||
description: '',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
@@ -541,8 +651,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Bad Proto',
|
||||
description: '',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
@@ -685,8 +794,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Small',
|
||||
description: '',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
@@ -708,8 +816,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Small',
|
||||
description: '',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
@@ -718,7 +825,7 @@ describe('Quota Store API', () => {
|
||||
await expect(res.json()).resolves.toEqual({ error: 'invalid_cloud_response' })
|
||||
})
|
||||
|
||||
it('rejects price currencies Cloud does not accept', async () => {
|
||||
it('rejects prices with empty currency strings', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -727,11 +834,10 @@ describe('Quota Store API', () => {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Euro',
|
||||
name: 'Bad Currency',
|
||||
description: '',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
prices: [{ currency: 'eur', amount: 500 }],
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: '', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -757,8 +863,7 @@ describe('Quota Store API', () => {
|
||||
body: JSON.stringify({
|
||||
name: 'Small',
|
||||
description: '',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 4096,
|
||||
storageBytes: 4096,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,24 +4,44 @@ export const quotaStoreSettingsSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export const quotaStoreResourceTypeSchema = z.enum(['storage', 'traffic'])
|
||||
export const quotaStoreCurrencySchema = z.enum(['usd', 'cny'])
|
||||
const quotaStoreResourceTypeSchema = z.enum(['storage', 'traffic'])
|
||||
export const quotaStoreCurrencySchema = z.string().min(1)
|
||||
export const quotaStorePackagePriceSchema = z.object({
|
||||
currency: quotaStoreCurrencySchema,
|
||||
amount: z.number().int().positive(),
|
||||
})
|
||||
|
||||
export const quotaStorePackageInputSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(1000).default(''),
|
||||
resourceType: quotaStoreResourceTypeSchema,
|
||||
resourceBytes: z.number().int().positive(),
|
||||
prices: z.array(quotaStorePackagePriceSchema).min(1),
|
||||
active: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
export const quotaStorePackageInputSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(1000).default(''),
|
||||
storageBytes: z.number().int().min(0).default(0),
|
||||
trafficBytes: z.number().int().min(0).default(0),
|
||||
prices: z.array(quotaStorePackagePriceSchema).min(1),
|
||||
active: z.boolean().default(true),
|
||||
sortOrder: z.number().int().default(0),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.storageBytes === 0 && data.trafficBytes === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['storageBytes'],
|
||||
message: 'At least one of storageBytes or trafficBytes must be greater than 0',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const quotaStorePackagePatchSchema = quotaStorePackageInputSchema.partial()
|
||||
export const quotaStorePackagePatchSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(1000),
|
||||
storageBytes: z.number().int().min(0),
|
||||
trafficBytes: z.number().int().min(0),
|
||||
prices: z.array(quotaStorePackagePriceSchema).min(1),
|
||||
active: z.boolean(),
|
||||
sortOrder: z.number().int(),
|
||||
})
|
||||
.partial()
|
||||
|
||||
export const checkoutInputSchema = z.object({
|
||||
packageId: z.string().min(1),
|
||||
@@ -82,7 +102,6 @@ export const cloudDeliveryEventSchema = z
|
||||
})
|
||||
|
||||
export type QuotaStoreSettingsInput = z.infer<typeof quotaStoreSettingsSchema>
|
||||
export type QuotaStoreResourceType = z.infer<typeof quotaStoreResourceTypeSchema>
|
||||
export type QuotaStoreCurrency = z.infer<typeof quotaStoreCurrencySchema>
|
||||
export type QuotaStorePackagePrice = z.infer<typeof quotaStorePackagePriceSchema>
|
||||
export type QuotaStorePackageInput = z.input<typeof quotaStorePackageInputSchema>
|
||||
|
||||
@@ -59,8 +59,8 @@ export interface QuotaStorePackage {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
resourceType: 'storage' | 'traffic'
|
||||
resourceBytes: number
|
||||
storageBytes: number
|
||||
trafficBytes: number
|
||||
prices: QuotaStorePackagePrice[]
|
||||
active: boolean
|
||||
sortOrder: number
|
||||
@@ -69,7 +69,7 @@ export interface QuotaStorePackage {
|
||||
}
|
||||
|
||||
export interface QuotaStorePackagePrice {
|
||||
currency: 'usd' | 'cny'
|
||||
currency: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ type Unit = keyof typeof units
|
||||
export const emptyPackageForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
resourceType: 'storage' as 'storage' | 'traffic',
|
||||
size: '100',
|
||||
unit: 'GB' as Unit,
|
||||
storageSize: '',
|
||||
storageUnit: 'GB' as Unit,
|
||||
trafficSize: '',
|
||||
trafficUnit: 'GB' as Unit,
|
||||
usdAmount: '999',
|
||||
cnyAmount: '',
|
||||
sortOrder: '0',
|
||||
@@ -29,21 +30,23 @@ export function packageInputFromForm(form: PackageFormState): QuotaStorePackageI
|
||||
return {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
resourceType: form.resourceType,
|
||||
resourceBytes: Math.round(Number(form.size) * units[form.unit]),
|
||||
storageBytes: form.storageSize ? Math.round(Number(form.storageSize) * units[form.storageUnit]) : 0,
|
||||
trafficBytes: form.trafficSize ? Math.round(Number(form.trafficSize) * units[form.trafficUnit]) : 0,
|
||||
prices: packagePricesFromForm(form),
|
||||
sortOrder: Math.round(Number(form.sortOrder)),
|
||||
}
|
||||
}
|
||||
|
||||
export function packageFormFromPackage(pkg: QuotaStorePackage): PackageFormState {
|
||||
const display = bytesToDisplay(pkg.resourceBytes)
|
||||
const storageDisplay = pkg.storageBytes > 0 ? bytesToDisplay(pkg.storageBytes) : null
|
||||
const trafficDisplay = pkg.trafficBytes > 0 ? bytesToDisplay(pkg.trafficBytes) : null
|
||||
return {
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
resourceType: pkg.resourceType,
|
||||
size: String(display.size),
|
||||
unit: display.unit,
|
||||
storageSize: storageDisplay ? String(storageDisplay.size) : '',
|
||||
storageUnit: storageDisplay?.unit ?? 'GB',
|
||||
trafficSize: trafficDisplay ? String(trafficDisplay.size) : '',
|
||||
trafficUnit: trafficDisplay?.unit ?? 'GB',
|
||||
usdAmount: String(pkg.prices.find((price) => price.currency === 'usd')?.amount ?? ''),
|
||||
cnyAmount: String(pkg.prices.find((price) => price.currency === 'cny')?.amount ?? ''),
|
||||
sortOrder: String(pkg.sortOrder),
|
||||
@@ -69,11 +72,32 @@ export function StoragePlanForm({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const storageBytes = form.storageSize ? Math.round(Number(form.storageSize) * units[form.storageUnit]) : 0
|
||||
const trafficBytes = form.trafficSize ? Math.round(Number(form.trafficSize) * units[form.trafficUnit]) : 0
|
||||
const quotaValid = storageBytes > 0 || trafficBytes > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PackageIdentityFields form={form} onFormChange={onFormChange} />
|
||||
<PackageResourceTypeField form={form} onFormChange={onFormChange} />
|
||||
<PackageSizeFields form={form} onFormChange={onFormChange} />
|
||||
<PackageQuotaFields
|
||||
label={t('admin.storagePlans.storageQuota')}
|
||||
sizeId="packageStorageSize"
|
||||
sizeValue={form.storageSize}
|
||||
unit={form.storageUnit}
|
||||
onSizeChange={(storageSize) => onFormChange({ ...form, storageSize })}
|
||||
onUnitChange={(storageUnit) => onFormChange({ ...form, storageUnit })}
|
||||
/>
|
||||
<PackageQuotaFields
|
||||
label={t('admin.storagePlans.trafficQuota')}
|
||||
sizeId="packageTrafficSize"
|
||||
sizeValue={form.trafficSize}
|
||||
unit={form.trafficUnit}
|
||||
onSizeChange={(trafficSize) => onFormChange({ ...form, trafficSize })}
|
||||
onUnitChange={(trafficUnit) => onFormChange({ ...form, trafficUnit })}
|
||||
/>
|
||||
{!quotaValid && (form.storageSize !== '' || form.trafficSize !== '') && (
|
||||
<p className="text-xs text-destructive">{t('admin.storagePlans.quotaRequired')}</p>
|
||||
)}
|
||||
<PackageAmountFields form={form} onFormChange={onFormChange} />
|
||||
<NumberField
|
||||
label={t('admin.storagePlans.sortOrder')}
|
||||
@@ -83,7 +107,7 @@ export function StoragePlanForm({
|
||||
/>
|
||||
<PackageFormActions
|
||||
editing={editing}
|
||||
available={available}
|
||||
available={available && quotaValid}
|
||||
pending={pending}
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
@@ -133,56 +157,41 @@ function PackageIdentityFields({
|
||||
)
|
||||
}
|
||||
|
||||
function PackageSizeFields({
|
||||
form,
|
||||
onFormChange,
|
||||
function PackageQuotaFields({
|
||||
label,
|
||||
sizeId,
|
||||
sizeValue,
|
||||
unit,
|
||||
onSizeChange,
|
||||
onUnitChange,
|
||||
}: {
|
||||
form: PackageFormState
|
||||
onFormChange: (form: PackageFormState) => void
|
||||
label: string
|
||||
sizeId: string
|
||||
sizeValue: string
|
||||
unit: Unit
|
||||
onSizeChange: (value: string) => void
|
||||
onUnitChange: (unit: Unit) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_96px] gap-2">
|
||||
<NumberField
|
||||
label={t('admin.storagePlans.size')}
|
||||
id="packageSize"
|
||||
min="1"
|
||||
value={form.size}
|
||||
onChange={(size) => onFormChange({ ...form, size })}
|
||||
/>
|
||||
<Field label={label} htmlFor={sizeId}>
|
||||
<Input
|
||||
id={sizeId}
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder={t('admin.storagePlans.quotaOptionalHint')}
|
||||
value={sizeValue}
|
||||
onChange={(e) => onSizeChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('admin.storagePlans.unit')}>
|
||||
<UnitSelect value={form.unit} onChange={(unit) => onFormChange({ ...form, unit })} />
|
||||
<UnitSelect value={unit} onChange={onUnitChange} ariaLabel={`${label} unit`} />
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PackageResourceTypeField({
|
||||
form,
|
||||
onFormChange,
|
||||
}: {
|
||||
form: PackageFormState
|
||||
onFormChange: (form: PackageFormState) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Field label={t('admin.storagePlans.resourceType')}>
|
||||
<Select
|
||||
value={form.resourceType}
|
||||
onValueChange={(resourceType: 'storage' | 'traffic') => onFormChange({ ...form, resourceType })}
|
||||
>
|
||||
<SelectTrigger aria-label={t('admin.storagePlans.resourceType')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="storage">{t('admin.storagePlans.resourceStorage')}</SelectItem>
|
||||
<SelectItem value="traffic">{t('admin.storagePlans.resourceTraffic')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function PackageAmountFields({
|
||||
form,
|
||||
onFormChange,
|
||||
@@ -231,10 +240,18 @@ function NumberField({
|
||||
)
|
||||
}
|
||||
|
||||
function UnitSelect({ value, onChange }: { value: Unit; onChange: (unit: Unit) => void }) {
|
||||
function UnitSelect({
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: Unit
|
||||
onChange: (unit: Unit) => void
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger aria-label={value}>
|
||||
<SelectTrigger aria-label={ariaLabel ?? value}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -27,7 +27,8 @@ export function StoragePlanList({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('admin.storagePlans.packageName')}</TableHead>
|
||||
<TableHead>{t('admin.storagePlans.size')}</TableHead>
|
||||
<TableHead>{t('admin.storagePlans.storageQuota')}</TableHead>
|
||||
<TableHead>{t('admin.storagePlans.trafficQuota')}</TableHead>
|
||||
<TableHead>{t('admin.storagePlans.prices')}</TableHead>
|
||||
<TableHead>{t('admin.storagePlans.active')}</TableHead>
|
||||
<TableHead>{t('admin.storagePlans.sortOrder')}</TableHead>
|
||||
@@ -41,7 +42,12 @@ export function StoragePlanList({
|
||||
<div className="font-medium">{pkg.name}</div>
|
||||
<div className="mt-1 whitespace-normal text-xs text-muted-foreground">{pkg.description}</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatSize(pkg.resourceBytes)}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{pkg.storageBytes > 0 ? formatSize(pkg.storageBytes) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{pkg.trafficBytes > 0 ? formatSize(pkg.trafficBytes) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{formatPrices(pkg.prices)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={pkg.active ? 'default' : 'secondary'}>
|
||||
|
||||
@@ -35,8 +35,8 @@ function quotaPackage(): QuotaStorePackage {
|
||||
id: 'pkg-1',
|
||||
name: '100 GB',
|
||||
description: 'Extra storage',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 107374182400,
|
||||
storageBytes: 107374182400,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'usd', amount: 999 }],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
|
||||
@@ -29,7 +29,7 @@ export function StorageActions({
|
||||
packagesDisabled: boolean
|
||||
redeemDisabled: boolean
|
||||
onCodeChange: (code: string) => void
|
||||
onCheckout: (packageId: string, currency: 'usd' | 'cny') => void
|
||||
onCheckout: (packageId: string, currency: string) => void
|
||||
onRedeem: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -123,7 +123,7 @@ function PackagePanel({
|
||||
}: {
|
||||
packages: QuotaStorePackage[]
|
||||
disabled: boolean
|
||||
onCheckout: (packageId: string, currency: 'usd' | 'cny') => void
|
||||
onCheckout: (packageId: string, currency: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -158,10 +158,10 @@ function PackageOption({
|
||||
}: {
|
||||
pkg: QuotaStorePackage
|
||||
disabled: boolean
|
||||
onCheckout: (currency: 'usd' | 'cny') => void
|
||||
onCheckout: (currency: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const Icon = pkg.resourceType === 'storage' ? HardDrive : Activity
|
||||
const Icon = pkg.storageBytes > 0 && pkg.trafficBytes > 0 ? HardDrive : pkg.trafficBytes > 0 ? Activity : HardDrive
|
||||
return (
|
||||
<div className="group flex flex-col justify-between rounded-md border bg-card p-3 transition-colors hover:border-primary/40 hover:bg-muted/30">
|
||||
<div className="space-y-2.5">
|
||||
@@ -175,7 +175,14 @@ function PackageOption({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-semibold tracking-normal">{formatSize(pkg.resourceBytes)}</p>
|
||||
{pkg.storageBytes > 0 && (
|
||||
<p className="text-xl font-semibold tracking-normal">{formatSize(pkg.storageBytes)}</p>
|
||||
)}
|
||||
{pkg.trafficBytes > 0 && (
|
||||
<p className={pkg.storageBytes > 0 ? 'text-sm font-medium' : 'text-xl font-semibold tracking-normal'}>
|
||||
{t('storage.trafficQuota', { size: formatSize(pkg.trafficBytes) })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{formatPrices(pkg.prices)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function StoragePackages({
|
||||
}: {
|
||||
packages: QuotaStorePackage[]
|
||||
disabled: boolean
|
||||
onCheckout: (packageId: string, currency: 'usd' | 'cny') => void
|
||||
onCheckout: (packageId: string, currency: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -76,7 +76,7 @@ function PackageCard({
|
||||
}: {
|
||||
pkg: QuotaStorePackage
|
||||
disabled: boolean
|
||||
onCheckout: (packageId: string, currency: 'usd' | 'cny') => void
|
||||
onCheckout: (packageId: string, currency: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -86,7 +86,12 @@ function PackageCard({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{formatSize(pkg.resourceBytes)}</p>
|
||||
{pkg.storageBytes > 0 && <p className="text-2xl font-semibold">{formatSize(pkg.storageBytes)}</p>}
|
||||
{pkg.trafficBytes > 0 && (
|
||||
<p className={pkg.storageBytes > 0 ? 'text-sm font-medium' : 'text-2xl font-semibold'}>
|
||||
{t('storage.trafficQuota', { size: formatSize(pkg.trafficBytes) })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{formatPrices(pkg.prices)}</p>
|
||||
</div>
|
||||
{pkg.prices.map((price) => (
|
||||
@@ -106,7 +111,7 @@ function PackageCard({
|
||||
}
|
||||
|
||||
function PackageHeader({ pkg }: { pkg: QuotaStorePackage }) {
|
||||
const Icon = pkg.resourceType === 'storage' ? HardDrive : Activity
|
||||
const Icon = pkg.storageBytes > 0 && pkg.trafficBytes > 0 ? HardDrive : pkg.trafficBytes > 0 ? Activity : HardDrive
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -1060,9 +1060,10 @@
|
||||
"admin.storagePlans.description": "Description",
|
||||
"admin.storagePlans.size": "Size",
|
||||
"admin.storagePlans.unit": "Unit",
|
||||
"admin.storagePlans.resourceType": "Resource type",
|
||||
"admin.storagePlans.resourceStorage": "Storage",
|
||||
"admin.storagePlans.resourceTraffic": "Traffic",
|
||||
"admin.storagePlans.storageQuota": "Storage quota",
|
||||
"admin.storagePlans.trafficQuota": "Download traffic quota",
|
||||
"admin.storagePlans.quotaOptionalHint": "Leave blank to omit",
|
||||
"admin.storagePlans.quotaRequired": "At least one of storage quota or download traffic quota must be set.",
|
||||
"admin.storagePlans.prices": "Prices",
|
||||
"admin.storagePlans.usdAmount": "USD amount (minor units)",
|
||||
"admin.storagePlans.cnyAmount": "CNY amount (minor units)",
|
||||
@@ -1137,5 +1138,6 @@
|
||||
"storage.historyTitle": "Recent grants",
|
||||
"storage.historyDescription": "Recent purchased and redeemed storage for the current workspace.",
|
||||
"storage.noPackages": "No storage packages are available right now.",
|
||||
"storage.trafficQuota": "{{size}} download traffic",
|
||||
"storage.noHistory": "No storage grants yet."
|
||||
}
|
||||
|
||||
@@ -1060,9 +1060,10 @@
|
||||
"admin.storagePlans.description": "描述",
|
||||
"admin.storagePlans.size": "容量",
|
||||
"admin.storagePlans.unit": "单位",
|
||||
"admin.storagePlans.resourceType": "资源类型",
|
||||
"admin.storagePlans.resourceStorage": "存储",
|
||||
"admin.storagePlans.resourceTraffic": "流量",
|
||||
"admin.storagePlans.storageQuota": "存储配额",
|
||||
"admin.storagePlans.trafficQuota": "下载流量配额",
|
||||
"admin.storagePlans.quotaOptionalHint": "留空则不包含",
|
||||
"admin.storagePlans.quotaRequired": "存储配额和下载流量配额至少填写一项。",
|
||||
"admin.storagePlans.prices": "价格",
|
||||
"admin.storagePlans.usdAmount": "美元金额(最小货币单位)",
|
||||
"admin.storagePlans.cnyAmount": "人民币金额(最小货币单位)",
|
||||
@@ -1137,5 +1138,6 @@
|
||||
"storage.historyTitle": "最近授予",
|
||||
"storage.historyDescription": "当前工作空间的近期购买和兑换记录。",
|
||||
"storage.noPackages": "当前暂无可用存储套餐。",
|
||||
"storage.trafficQuota": "{{size}} 下载流量",
|
||||
"storage.noHistory": "暂无存储授予记录。"
|
||||
}
|
||||
|
||||
+7
-12
@@ -301,11 +301,10 @@ describe('api', () => {
|
||||
it('creates packages with typed RPC paths', async () => {
|
||||
const payload: Parameters<typeof createQuotaStorePackage>[0] = {
|
||||
name: 'Small',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 1024,
|
||||
storageBytes: 1024,
|
||||
prices: [
|
||||
{ currency: 'usd' as const, amount: 500 },
|
||||
{ currency: 'cny' as const, amount: 3600 },
|
||||
{ currency: 'usd', amount: 500 },
|
||||
{ currency: 'cny', amount: 3600 },
|
||||
],
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'pkg-1' }))
|
||||
@@ -317,8 +316,7 @@ describe('api', () => {
|
||||
expect(createInit.method).toBe('POST')
|
||||
expect(JSON.parse(createInit.body as string)).toEqual({
|
||||
name: 'Small',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 1024,
|
||||
storageBytes: 1024,
|
||||
prices: [
|
||||
{ currency: 'usd', amount: 500 },
|
||||
{ currency: 'cny', amount: 3600 },
|
||||
@@ -345,8 +343,7 @@ describe('api', () => {
|
||||
await expect(
|
||||
createQuotaStorePackage({
|
||||
name: 'Small',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 1024,
|
||||
storageBytes: 1024,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
).rejects.toThrow('package create failed')
|
||||
@@ -443,8 +440,7 @@ describe('api', () => {
|
||||
() =>
|
||||
createQuotaStorePackage({
|
||||
name: 'Small',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 1024,
|
||||
storageBytes: 1024,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
],
|
||||
@@ -453,8 +449,7 @@ describe('api', () => {
|
||||
() =>
|
||||
updateQuotaStorePackage('pkg-1', {
|
||||
name: 'Small',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 1024,
|
||||
storageBytes: 1024,
|
||||
prices: [{ currency: 'usd', amount: 500 }],
|
||||
}),
|
||||
],
|
||||
|
||||
+1
-1
@@ -369,7 +369,7 @@ export function listQuotaStoreTargets() {
|
||||
return unwrap<{ items: QuotaTarget[]; total: number }>(quotaStoreApi.targets.$get())
|
||||
}
|
||||
|
||||
export function createQuotaCheckout(packageId: string, targetOrgId: string, currency?: 'usd' | 'cny') {
|
||||
export function createQuotaCheckout(packageId: string, targetOrgId: string, currency?: string) {
|
||||
return unwrap<{ checkoutUrl: string }>(quotaStoreApi.checkouts.$post({ json: { packageId, targetOrgId, currency } }))
|
||||
}
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ function quotaPackage(overrides: Partial<QuotaStorePackage> = {}): QuotaStorePac
|
||||
id: 'pkg-1',
|
||||
name: '100 GB',
|
||||
description: 'Extra storage',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 107374182400,
|
||||
storageBytes: 107374182400,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'usd', amount: 999 }],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
@@ -146,7 +146,7 @@ describe('AdminStoragePlansPage', () => {
|
||||
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.newPackage' }))
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.packageName'), { target: { value: '250 GB' } })
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.description'), { target: { value: 'Team storage' } })
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.size'), { target: { value: '250' } })
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.storageQuota'), { target: { value: '250' } })
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.usdAmount'), { target: { value: '1999' } })
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.cnyAmount'), { target: { value: '12900' } })
|
||||
fireEvent.change(view.getByLabelText('admin.storagePlans.sortOrder'), { target: { value: '2' } })
|
||||
@@ -156,8 +156,8 @@ describe('AdminStoragePlansPage', () => {
|
||||
expect(createQuotaStorePackage).toHaveBeenCalledWith({
|
||||
name: '250 GB',
|
||||
description: 'Team storage',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 268435456000,
|
||||
storageBytes: 268435456000,
|
||||
trafficBytes: 0,
|
||||
prices: [
|
||||
{ currency: 'usd', amount: 1999 },
|
||||
{ currency: 'cny', amount: 12900 },
|
||||
@@ -168,10 +168,12 @@ describe('AdminStoragePlansPage', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.storagePlans.packageSaved')
|
||||
})
|
||||
|
||||
it('creates a traffic package with USD and CNY prices', async () => {
|
||||
it('creates a traffic-only package with USD and CNY prices', async () => {
|
||||
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
|
||||
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(createQuotaStorePackage).mockResolvedValue(quotaPackage({ id: 'pkg-traffic', resourceType: 'traffic' }))
|
||||
vi.mocked(createQuotaStorePackage).mockResolvedValue(
|
||||
quotaPackage({ id: 'pkg-traffic', trafficBytes: 1099511627776, storageBytes: 0 }),
|
||||
)
|
||||
|
||||
const view = renderAdminPage()
|
||||
|
||||
@@ -184,10 +186,8 @@ describe('AdminStoragePlansPage', () => {
|
||||
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.description'), {
|
||||
target: { value: 'Download traffic' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByLabelText('admin.storagePlans.resourceType'))
|
||||
fireEvent.click(await view.findByRole('option', { name: 'admin.storagePlans.resourceTraffic' }))
|
||||
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.size'), { target: { value: '1' } })
|
||||
fireEvent.click(within(dialog).getByLabelText('GB'))
|
||||
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.trafficQuota'), { target: { value: '1' } })
|
||||
fireEvent.click(within(dialog).getByLabelText('admin.storagePlans.trafficQuota unit'))
|
||||
fireEvent.click(await view.findByRole('option', { name: 'TB' }))
|
||||
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.usdAmount'), { target: { value: '4999' } })
|
||||
fireEvent.change(within(dialog).getByLabelText('admin.storagePlans.cnyAmount'), { target: { value: '32900' } })
|
||||
@@ -197,8 +197,8 @@ describe('AdminStoragePlansPage', () => {
|
||||
expect(createQuotaStorePackage).toHaveBeenCalledWith({
|
||||
name: '1 TB traffic',
|
||||
description: 'Download traffic',
|
||||
resourceType: 'traffic',
|
||||
resourceBytes: 1099511627776,
|
||||
storageBytes: 0,
|
||||
trafficBytes: 1099511627776,
|
||||
prices: [
|
||||
{ currency: 'usd', amount: 4999 },
|
||||
{ currency: 'cny', amount: 32900 },
|
||||
@@ -262,8 +262,8 @@ describe('AdminStoragePlansPage', () => {
|
||||
expect(updateQuotaStorePackage).toHaveBeenCalledWith('pkg-1', {
|
||||
name: '200 GB',
|
||||
description: 'Extra storage',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 107374182400,
|
||||
storageBytes: 107374182400,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'usd', amount: 999 }],
|
||||
sortOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -84,8 +84,8 @@ function quotaPackage(): QuotaStorePackage {
|
||||
id: 'pkg-1',
|
||||
name: '100 GB',
|
||||
description: 'Extra storage',
|
||||
resourceType: 'storage',
|
||||
resourceBytes: 107374182400,
|
||||
storageBytes: 107374182400,
|
||||
trafficBytes: 0,
|
||||
prices: [{ currency: 'usd', amount: 999 }],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
|
||||
@@ -74,14 +74,8 @@ export function StoragePage() {
|
||||
}, [checkoutRefreshActive, queryClient])
|
||||
|
||||
const checkoutMutation = useMutation({
|
||||
mutationFn: ({
|
||||
packageId,
|
||||
currency,
|
||||
}: {
|
||||
packageId: string
|
||||
currency: 'usd' | 'cny'
|
||||
checkoutWindow: Window | null
|
||||
}) => createQuotaCheckout(packageId, targetOrgId, currency),
|
||||
mutationFn: ({ packageId, currency }: { packageId: string; currency: string; checkoutWindow: Window | null }) =>
|
||||
createQuotaCheckout(packageId, targetOrgId, currency),
|
||||
onSuccess: (result, variables) => {
|
||||
if (variables.checkoutWindow) {
|
||||
variables.checkoutWindow.location.href = result.checkoutUrl
|
||||
@@ -98,7 +92,7 @@ export function StoragePage() {
|
||||
},
|
||||
})
|
||||
|
||||
function startCheckout(packageId: string, currency: 'usd' | 'cny') {
|
||||
function startCheckout(packageId: string, currency: string) {
|
||||
const checkoutWindow = window.open('about:blank', '_blank')
|
||||
if (checkoutWindow) checkoutWindow.opener = null
|
||||
checkoutMutation.mutate({ packageId, currency, checkoutWindow })
|
||||
|
||||
Reference in New Issue
Block a user