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:
Jasper Van
2026-05-06 22:48:56 -04:00
committed by GitHub
parent be58015ab2
commit 9cabe3ccc1
16 changed files with 365 additions and 159 deletions
+59 -5
View File
@@ -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([
+132 -27
View File
@@ -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 }],
}),
})
+32 -13
View File
@@ -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>
+3 -3
View File
@@ -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
}
+70 -53
View File
@@ -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>
+8 -2
View File
@@ -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'}>
+2 -2
View File
@@ -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,
+12 -5
View File
@@ -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>
+9 -4
View File
@@ -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>
+5 -3
View File
@@ -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."
}
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}),
+2 -2
View File
@@ -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,
+3 -9
View File
@@ -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 })