mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
feat(workspaces): add billing settings tab
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import type { CloudProduct } from '@shared/types'
|
||||
import { openNewTab } from '@/lib/browser-navigation'
|
||||
import type { CheckoutSelection } from './checkout-confirm-dialog'
|
||||
|
||||
type CheckoutTabInput =
|
||||
| { action: 'checkout'; packageId: string; priceId: string; promotionCode?: string }
|
||||
| { action: 'payment'; orderId: string }
|
||||
| { action: 'portal' }
|
||||
|
||||
export function openCheckoutTab(input: CheckoutTabInput) {
|
||||
const search = new URLSearchParams({ action: input.action })
|
||||
if (input.action === 'checkout') {
|
||||
search.set('packageId', input.packageId)
|
||||
search.set('priceId', input.priceId)
|
||||
if (input.promotionCode) search.set('promotionCode', input.promotionCode)
|
||||
}
|
||||
if (input.action === 'payment') search.set('orderId', input.orderId)
|
||||
openNewTab(`/store/checkout?${search.toString()}`)
|
||||
}
|
||||
|
||||
export function resolveCheckoutSelection(
|
||||
products: CloudProduct[],
|
||||
packageId: string,
|
||||
priceId: string,
|
||||
): CheckoutSelection | null {
|
||||
const product = products.find((item) => item.id === packageId)
|
||||
const price = product?.prices.find((item) => item.id === priceId)
|
||||
if (!product || !price) return null
|
||||
const interval = price.recurring?.interval
|
||||
return {
|
||||
packageId,
|
||||
priceId,
|
||||
productName: product.name,
|
||||
amount: price.amount,
|
||||
currency: price.currency,
|
||||
interval: interval === 'month' || interval === 'year' ? interval : null,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { CloudCreditLedgerEntry } from '@shared/schemas'
|
||||
import type { CloudProduct } from '@shared/types'
|
||||
import { BadgeCent, PlusCircle } from 'lucide-react'
|
||||
import { BadgeCent, CircleDollarSign } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -16,7 +19,7 @@ import { cloudProductIncludedCredits } from '@/lib/cloud-product'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import { StorageActions } from './storage-dialogs'
|
||||
|
||||
export function CreditBalanceButton({
|
||||
export function CreditBillingPanel({
|
||||
credits,
|
||||
products,
|
||||
entries,
|
||||
@@ -25,6 +28,7 @@ export function CreditBalanceButton({
|
||||
onCheckout,
|
||||
isRedeeming,
|
||||
checkoutDisabled,
|
||||
accountAction,
|
||||
}: {
|
||||
credits?: { balance: number }
|
||||
products: CloudProduct[]
|
||||
@@ -34,37 +38,37 @@ export function CreditBalanceButton({
|
||||
onCheckout: (packageId: string, priceId: string) => void
|
||||
isRedeeming: boolean
|
||||
checkoutDisabled: boolean
|
||||
accountAction?: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label={t('storage.viewCreditActivity')}
|
||||
>
|
||||
<BadgeCent className="h-4 w-4" />
|
||||
{t('storage.creditsButton')}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('storage.creditsButton')}</DialogTitle>
|
||||
<DialogDescription>{t('storage.creditActivityDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CreditBalanceSummary credits={credits} onRedeem={onRedeem} isRedeeming={isRedeeming} />
|
||||
<CreditProducts products={products} disabled={checkoutDisabled} onCheckout={onCheckout} />
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t('storage.creditActivityTitle')}</h3>
|
||||
<CreditActivity entries={entries} loading={loading} />
|
||||
<section className="space-y-6">
|
||||
<div className="flex min-h-32 flex-wrap items-center justify-between gap-6 rounded-lg border bg-card px-6 py-5">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<BadgeCent className="size-4" />
|
||||
<span>{t('storage.creditBalance')}</span>
|
||||
</div>
|
||||
<div className="text-4xl font-semibold tracking-tight tabular-nums">
|
||||
{credits ? formatCredits(credits.balance) : t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CreditTopUpDialog products={products} disabled={checkoutDisabled} onCheckout={onCheckout} />
|
||||
<StorageActions onRedeem={onRedeem} isRedeeming={isRedeeming} />
|
||||
{accountAction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">{t('storage.creditActivityTitle')}</h3>
|
||||
<CreditActivity entries={entries} loading={loading} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditProducts({
|
||||
function CreditTopUpDialog({
|
||||
products,
|
||||
disabled,
|
||||
onCheckout,
|
||||
@@ -74,63 +78,82 @@ function CreditProducts({
|
||||
onCheckout: (packageId: string, priceId: string) => void
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const language = i18n.resolvedLanguage ?? 'en'
|
||||
const [open, setOpen] = useState(false)
|
||||
const purchasableProducts = products
|
||||
.map((product) => ({ product, price: oneTimeUsdPrice(product) }))
|
||||
.filter((item): item is { product: CloudProduct; price: CloudProduct['prices'][number] & { id: string } } =>
|
||||
Boolean(item.price),
|
||||
)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const selected = purchasableProducts.find(({ product }) => product.id === selectedId) ?? purchasableProducts[0]
|
||||
|
||||
if (purchasableProducts.length === 0) return null
|
||||
function continueCheckout() {
|
||||
if (!selected) return
|
||||
setOpen(false)
|
||||
onCheckout(selected.product.id, selected.price.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t('storage.creditTopUpTitle')}</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{purchasableProducts.map(({ product, price }) => (
|
||||
<div key={product.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{product.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t('storage.creditTopUpAmount', { amount: formatCredits(cloudProductIncludedCredits(product)) })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(price.amount, price.currency, language)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="mt-3 h-8 w-full" disabled={disabled} onClick={() => onCheckout(product.id, price.id)}>
|
||||
<PlusCircle className="h-3.5 w-3.5" />
|
||||
{t('storage.buyCredits')}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={disabled}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
{t('storage.creditTopUpTitle')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('storage.creditTopUpTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('storage.creditTopUpDialogDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{purchasableProducts.length > 0 ? (
|
||||
<div className="space-y-2 py-2">
|
||||
{purchasableProducts.map(({ product, price }) => {
|
||||
const selectedProduct = selected?.product.id === product.id
|
||||
return (
|
||||
<label
|
||||
key={product.id}
|
||||
className={`flex cursor-pointer items-center justify-between gap-4 rounded-md border px-4 py-3 transition-colors ${
|
||||
selectedProduct ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="credit-top-up"
|
||||
value={product.id}
|
||||
checked={selectedProduct}
|
||||
className="size-4 shrink-0 accent-primary"
|
||||
onChange={() => setSelectedId(product.id)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{t('storage.creditTopUpAmount', {
|
||||
amount: formatCredits(cloudProductIncludedCredits(product)),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold tabular-nums">
|
||||
{formatCurrency(price.amount, price.currency, i18n.resolvedLanguage ?? 'en')}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreditBalanceSummary({
|
||||
credits,
|
||||
onRedeem,
|
||||
isRedeeming,
|
||||
}: {
|
||||
credits?: { balance: number }
|
||||
onRedeem: (code: string) => void
|
||||
isRedeeming: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-muted-foreground">{t('storage.creditBalance')}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tabular-nums">
|
||||
{credits ? formatCredits(credits.balance) : t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
<StorageActions onRedeem={onRedeem} isRedeeming={isRedeeming} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{t('storage.noCreditTopUps')}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button disabled={!selected} onClick={continueCheckout}>
|
||||
{t('storage.proceedToCheckout')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,7 +164,7 @@ function CreditActivity({ entries, loading }: { entries: CloudCreditLedgerEntry[
|
||||
if (entries.length === 0) return <CreditEmptyState label={t('storage.creditActivityEmpty')} />
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-auto rounded-lg border">
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full caption-bottom text-left text-sm">
|
||||
<thead className="sticky top-0 border-b bg-background">
|
||||
<tr>
|
||||
|
||||
@@ -20,20 +20,18 @@ export function StorageOrderHistoryDialog({
|
||||
orders,
|
||||
onContinuePayment,
|
||||
onCancelOrder,
|
||||
continuingOrderId,
|
||||
cancelingOrderId,
|
||||
}: {
|
||||
orders: CloudOrder[]
|
||||
onContinuePayment: (orderId: string) => void
|
||||
onCancelOrder: (orderId: string) => void
|
||||
continuingOrderId: string | null
|
||||
cancelingOrderId: string | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline">
|
||||
<ShoppingCart />
|
||||
{t('storage.historyTitle')}
|
||||
</Button>
|
||||
@@ -48,7 +46,6 @@ export function StorageOrderHistoryDialog({
|
||||
orders={orders}
|
||||
onContinuePayment={onContinuePayment}
|
||||
onCancelOrder={onCancelOrder}
|
||||
continuingOrderId={continuingOrderId}
|
||||
cancelingOrderId={cancelingOrderId}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +121,6 @@ function OrderRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{formatTargetValue(order, 'orgId')}</span>
|
||||
<span>{new Date(order.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,8 +204,3 @@ function isActionableOrder(order: CloudOrder) {
|
||||
if (order.status !== 'pending') return false
|
||||
return order.paymentStatus !== 'paid' && order.paymentStatus !== 'canceled'
|
||||
}
|
||||
|
||||
function formatTargetValue(order: CloudOrder, key: string) {
|
||||
const value = order.target?.[key]
|
||||
return typeof value === 'string' ? value : '-'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { CreditBalanceButton } from './credits-panel'
|
||||
export { StorageOrderHistoryContent, StorageOrderHistoryDialog } from './order-history'
|
||||
export { CreditBillingPanel } from './credits-panel'
|
||||
export { StorageOrderHistoryDialog } from './order-history'
|
||||
export { StorageActions } from './storage-dialogs'
|
||||
export { StoragePackages } from './storage-packages'
|
||||
export { StorageUnavailableState } from './storage-unavailable'
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('OrgSwitcher', () => {
|
||||
})
|
||||
|
||||
it('opens the first workspace settings tab when switching workspaces from settings', async () => {
|
||||
mocks.pathname = '/teams/personal-org/ihost'
|
||||
mocks.pathname = '/teams/personal-org/billing'
|
||||
const view = await openSwitcher()
|
||||
|
||||
fireEvent.click(view.getByRole('menuitem', { name: /Design Team/ }))
|
||||
|
||||
@@ -57,7 +57,7 @@ export function OrgSwitcher() {
|
||||
return
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['objects'] })
|
||||
const isWorkspaceSettings = /^\/teams\/[^/]+\/(activity|ihost|members|settings)$/.test(pathname)
|
||||
const isWorkspaceSettings = /^\/teams\/[^/]+\/(activity|billing|ihost|members|settings)$/.test(pathname)
|
||||
if (isWorkspaceSettings) {
|
||||
navigate({ to: '/teams/$teamId/settings', params: { teamId: org.id } })
|
||||
return
|
||||
|
||||
@@ -1188,6 +1188,7 @@
|
||||
"teams.tabMembers": "Members",
|
||||
"teams.tabActivity": "Activity",
|
||||
"teams.tabSettings": "Settings",
|
||||
"teams.tabBilling": "Billing",
|
||||
"teams.empty": "No teams yet. Create one to get started.",
|
||||
"teams.createNew": "Create Team",
|
||||
"teams.createTitle": "Create New Team",
|
||||
@@ -1917,6 +1918,8 @@
|
||||
"storage.creditActivityDescription": "Credit changes from gift-card redemptions, grants, top-ups, and usage charges.",
|
||||
"storage.creditActivityEmpty": "No credit activity yet.",
|
||||
"storage.creditTopUpTitle": "Credit top-ups",
|
||||
"storage.creditTopUpDialogDescription": "Choose a top-up package to add Credits to this workspace.",
|
||||
"storage.noCreditTopUps": "No Credit top-ups are available right now.",
|
||||
"storage.creditTopUpAmount": "{{amount}} Credits",
|
||||
"storage.buyCredits": "Buy Credits",
|
||||
"storage.creditTableType": "Type",
|
||||
@@ -1970,6 +1973,7 @@
|
||||
"storage.planAlreadyActive": "Plan already active",
|
||||
"storage.checkoutPending": "Waiting for Stripe payment confirmation. Quota and order status will refresh automatically.",
|
||||
"storage.teamMemberNotice": "Storage for this team space is managed by the team owner. Contact the owner if more capacity is needed.",
|
||||
"storage.teamMemberBillingNotice": "Billing for this team space is managed by the team owner.",
|
||||
"storage.checkoutRedirectTitle": "Preparing checkout",
|
||||
"storage.checkoutRedirectDescription": "Creating a secure payment session. You will be redirected automatically.",
|
||||
"storage.checkoutRedirectErrorTitle": "Checkout could not start",
|
||||
|
||||
@@ -1188,6 +1188,7 @@
|
||||
"teams.tabMembers": "成员",
|
||||
"teams.tabActivity": "动态",
|
||||
"teams.tabSettings": "设置",
|
||||
"teams.tabBilling": "账单",
|
||||
"teams.empty": "暂无团队,创建一个开始使用。",
|
||||
"teams.createNew": "创建团队",
|
||||
"teams.createTitle": "创建新团队",
|
||||
@@ -1917,6 +1918,8 @@
|
||||
"storage.creditActivityDescription": "展示礼品卡兑换、授予、充值和用量扣减带来的积分变动。",
|
||||
"storage.creditActivityEmpty": "暂无积分流水。",
|
||||
"storage.creditTopUpTitle": "积分充值",
|
||||
"storage.creditTopUpDialogDescription": "选择充值包,为当前空间增加积分。",
|
||||
"storage.noCreditTopUps": "当前暂无可购买的积分充值包。",
|
||||
"storage.creditTopUpAmount": "{{amount}} 积分",
|
||||
"storage.buyCredits": "购买积分",
|
||||
"storage.creditTableType": "类型",
|
||||
@@ -1970,6 +1973,7 @@
|
||||
"storage.planAlreadyActive": "已有生效套餐",
|
||||
"storage.checkoutPending": "正在等待 Stripe 支付确认,配额和订单状态会自动刷新。",
|
||||
"storage.teamMemberNotice": "该团队空间的存储由团队所有者管理,如需更多容量请联系所有者。",
|
||||
"storage.teamMemberBillingNotice": "该团队空间的账单由团队所有者管理。",
|
||||
"storage.checkoutRedirectTitle": "正在准备支付",
|
||||
"storage.checkoutRedirectDescription": "正在创建安全支付会话,稍后会自动跳转。",
|
||||
"storage.checkoutRedirectErrorTitle": "无法发起支付",
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_a
|
||||
import { Route as AuthenticatedTeamsTeamIdSettingsRouteImport } from './routes/_authenticated/teams/$teamId/settings'
|
||||
import { Route as AuthenticatedTeamsTeamIdMembersRouteImport } from './routes/_authenticated/teams/$teamId/members'
|
||||
import { Route as AuthenticatedTeamsTeamIdIhostRouteImport } from './routes/_authenticated/teams/$teamId/ihost'
|
||||
import { Route as AuthenticatedTeamsTeamIdBillingRouteImport } from './routes/_authenticated/teams/$teamId/billing'
|
||||
import { Route as AuthenticatedTeamsTeamIdActivityRouteImport } from './routes/_authenticated/teams/$teamId/activity'
|
||||
import { Route as AuthenticatedAdminUsersUserIdRouteImport } from './routes/_authenticated/admin/users/$userId'
|
||||
import { Route as AuthenticatedAdminTeamsOrgIdRouteImport } from './routes/_authenticated/admin/teams/$orgId'
|
||||
@@ -309,6 +310,12 @@ const AuthenticatedTeamsTeamIdIhostRoute =
|
||||
path: '/ihost',
|
||||
getParentRoute: () => AuthenticatedTeamsTeamIdRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedTeamsTeamIdBillingRoute =
|
||||
AuthenticatedTeamsTeamIdBillingRouteImport.update({
|
||||
id: '/billing',
|
||||
path: '/billing',
|
||||
getParentRoute: () => AuthenticatedTeamsTeamIdRouteRoute,
|
||||
} as any)
|
||||
const AuthenticatedTeamsTeamIdActivityRoute =
|
||||
AuthenticatedTeamsTeamIdActivityRouteImport.update({
|
||||
id: '/activity',
|
||||
@@ -382,6 +389,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/teams/$orgId': typeof AuthenticatedAdminTeamsOrgIdRoute
|
||||
'/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
|
||||
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
'/teams/$teamId/billing': typeof AuthenticatedTeamsTeamIdBillingRoute
|
||||
'/teams/$teamId/ihost': typeof AuthenticatedTeamsTeamIdIhostRoute
|
||||
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -430,6 +438,7 @@ export interface FileRoutesByTo {
|
||||
'/admin/teams/$orgId': typeof AuthenticatedAdminTeamsOrgIdRoute
|
||||
'/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
|
||||
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
'/teams/$teamId/billing': typeof AuthenticatedTeamsTeamIdBillingRoute
|
||||
'/teams/$teamId/ihost': typeof AuthenticatedTeamsTeamIdIhostRoute
|
||||
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -483,6 +492,7 @@ export interface FileRoutesById {
|
||||
'/_authenticated/admin/teams/$orgId': typeof AuthenticatedAdminTeamsOrgIdRoute
|
||||
'/_authenticated/admin/users/$userId': typeof AuthenticatedAdminUsersUserIdRoute
|
||||
'/_authenticated/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
'/_authenticated/teams/$teamId/billing': typeof AuthenticatedTeamsTeamIdBillingRoute
|
||||
'/_authenticated/teams/$teamId/ihost': typeof AuthenticatedTeamsTeamIdIhostRoute
|
||||
'/_authenticated/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
'/_authenticated/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -536,6 +546,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/teams/$orgId'
|
||||
| '/admin/users/$userId'
|
||||
| '/teams/$teamId/activity'
|
||||
| '/teams/$teamId/billing'
|
||||
| '/teams/$teamId/ihost'
|
||||
| '/teams/$teamId/members'
|
||||
| '/teams/$teamId/settings'
|
||||
@@ -584,6 +595,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/teams/$orgId'
|
||||
| '/admin/users/$userId'
|
||||
| '/teams/$teamId/activity'
|
||||
| '/teams/$teamId/billing'
|
||||
| '/teams/$teamId/ihost'
|
||||
| '/teams/$teamId/members'
|
||||
| '/teams/$teamId/settings'
|
||||
@@ -636,6 +648,7 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/admin/teams/$orgId'
|
||||
| '/_authenticated/admin/users/$userId'
|
||||
| '/_authenticated/teams/$teamId/activity'
|
||||
| '/_authenticated/teams/$teamId/billing'
|
||||
| '/_authenticated/teams/$teamId/ihost'
|
||||
| '/_authenticated/teams/$teamId/members'
|
||||
| '/_authenticated/teams/$teamId/settings'
|
||||
@@ -975,6 +988,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedTeamsTeamIdIhostRouteImport
|
||||
parentRoute: typeof AuthenticatedTeamsTeamIdRouteRoute
|
||||
}
|
||||
'/_authenticated/teams/$teamId/billing': {
|
||||
id: '/_authenticated/teams/$teamId/billing'
|
||||
path: '/billing'
|
||||
fullPath: '/teams/$teamId/billing'
|
||||
preLoaderRoute: typeof AuthenticatedTeamsTeamIdBillingRouteImport
|
||||
parentRoute: typeof AuthenticatedTeamsTeamIdRouteRoute
|
||||
}
|
||||
'/_authenticated/teams/$teamId/activity': {
|
||||
id: '/_authenticated/teams/$teamId/activity'
|
||||
path: '/activity'
|
||||
@@ -1081,6 +1101,7 @@ const AuthenticatedSettingsRouteRouteWithChildren =
|
||||
|
||||
interface AuthenticatedTeamsTeamIdRouteRouteChildren {
|
||||
AuthenticatedTeamsTeamIdActivityRoute: typeof AuthenticatedTeamsTeamIdActivityRoute
|
||||
AuthenticatedTeamsTeamIdBillingRoute: typeof AuthenticatedTeamsTeamIdBillingRoute
|
||||
AuthenticatedTeamsTeamIdIhostRoute: typeof AuthenticatedTeamsTeamIdIhostRoute
|
||||
AuthenticatedTeamsTeamIdMembersRoute: typeof AuthenticatedTeamsTeamIdMembersRoute
|
||||
AuthenticatedTeamsTeamIdSettingsRoute: typeof AuthenticatedTeamsTeamIdSettingsRoute
|
||||
@@ -1091,6 +1112,7 @@ const AuthenticatedTeamsTeamIdRouteRouteChildren: AuthenticatedTeamsTeamIdRouteR
|
||||
{
|
||||
AuthenticatedTeamsTeamIdActivityRoute:
|
||||
AuthenticatedTeamsTeamIdActivityRoute,
|
||||
AuthenticatedTeamsTeamIdBillingRoute: AuthenticatedTeamsTeamIdBillingRoute,
|
||||
AuthenticatedTeamsTeamIdIhostRoute: AuthenticatedTeamsTeamIdIhostRoute,
|
||||
AuthenticatedTeamsTeamIdMembersRoute: AuthenticatedTeamsTeamIdMembersRoute,
|
||||
AuthenticatedTeamsTeamIdSettingsRoute:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { CloudOrder, CloudProduct } from '@shared/types'
|
||||
import type { CloudProduct } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApiError,
|
||||
cancelCloudOrder,
|
||||
getCloudCredits,
|
||||
getUserQuota,
|
||||
listCloudCreditLedgerEntries,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudStoreTargets,
|
||||
redeemCloudGiftCard,
|
||||
} from '@/lib/api'
|
||||
import { openNewTab } from '@/lib/browser-navigation'
|
||||
import { StoragePage } from './storage'
|
||||
@@ -115,44 +113,6 @@ vi.mock('@/lib/api', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function order(overrides: Partial<CloudOrder> = {}): CloudOrder {
|
||||
return {
|
||||
id: 'order-1',
|
||||
storeId: 'store-1',
|
||||
buyerAccountId: 'buyer-1',
|
||||
target: { orgId: 'org-1' },
|
||||
status: 'paid',
|
||||
subtotalAmount: 999,
|
||||
discountAmount: 0,
|
||||
totalAmount: 999,
|
||||
currency: 'usd',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
orderId: 'order-1',
|
||||
productId: 'pkg-1',
|
||||
productType: 'store_item',
|
||||
name: '100 GB',
|
||||
description: null,
|
||||
quantity: 1,
|
||||
unitAmount: 999,
|
||||
totalAmount: 999,
|
||||
fulfillmentPayload: {
|
||||
deliverable: { type: 'zpan.plan', storageBytes: 1024, trafficBytes: 0, includedCredits: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
payments: [],
|
||||
paymentStatus: 'paid',
|
||||
fulfillmentStatus: 'fulfilled',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
paidAt: '2026-05-05T00:00:00.000Z',
|
||||
fulfilledAt: '2026-05-05T00:00:00.000Z',
|
||||
canceledAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function quotaPackage(): CloudProduct {
|
||||
return {
|
||||
id: 'pkg-1',
|
||||
@@ -233,31 +193,6 @@ function higherSubscriptionPackage(): CloudProduct {
|
||||
}
|
||||
}
|
||||
|
||||
function creditPackage(): CloudProduct {
|
||||
return {
|
||||
id: 'pkg-credits',
|
||||
storeId: 'store-1',
|
||||
type: 'store_item',
|
||||
name: '5,000 Credits',
|
||||
description: 'Credit top-up',
|
||||
metadata: {
|
||||
deliverable: { type: 'zpan.credits', includedCredits: 5000 },
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
id: 'price-credits-usd',
|
||||
currency: 'usd',
|
||||
amount: 2999,
|
||||
metadata: { creditGrantType: 'top_up', creditAmount: '5000' },
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
updatedAt: '2026-05-05T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function renderStoragePage(queryClient: QueryClient) {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -305,25 +240,6 @@ describe('StoragePage', () => {
|
||||
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 })
|
||||
})
|
||||
|
||||
it('refreshes quota when a checkout order is delivered', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({
|
||||
items: [order()],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['user', 'quota'] }))
|
||||
})
|
||||
|
||||
it('shows effective quota and plan credits status', async () => {
|
||||
vi.mocked(getUserQuota).mockResolvedValue({
|
||||
orgId: 'org-1',
|
||||
@@ -369,7 +285,7 @@ describe('StoragePage', () => {
|
||||
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
|
||||
expect(view.queryByText('storage.storageQuotaEntitlement')).toBeNull()
|
||||
expect(view.getAllByRole('progressbar')).toHaveLength(2)
|
||||
expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy()
|
||||
expect(view.queryByLabelText('storage.viewCreditActivity')).toBeNull()
|
||||
await waitFor(() => expect(view.getAllByText('1.5 KB').length).toBeGreaterThan(0))
|
||||
expect(view.getByText('storage.trafficPeriodDetail:2026-05')).toBeTruthy()
|
||||
})
|
||||
@@ -720,13 +636,9 @@ describe('StoragePage', () => {
|
||||
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=portal')
|
||||
})
|
||||
|
||||
it('uses the active workspace for orders and checkout', async () => {
|
||||
it('uses the active workspace for checkout', async () => {
|
||||
activeOrganization.value = { id: 'org-2' }
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [quotaPackage()], total: 1 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({
|
||||
items: [order({ id: 'order-2', target: { orgId: 'org-2' } })],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -736,12 +648,6 @@ describe('StoragePage', () => {
|
||||
})
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
|
||||
await waitFor(() => expect(view.getByText('org-2')).toBeTruthy())
|
||||
expect(vi.mocked(listCloudOrders)).toHaveBeenCalledWith()
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
await waitFor(() => expect(view.queryByText('org-2')).toBeNull())
|
||||
fireEvent.click(await view.findByRole('button', { name: /storage.checkoutMonthly/ }))
|
||||
fireEvent.click(await view.findByRole('button', { name: 'storage.proceedToCheckout' }))
|
||||
|
||||
@@ -837,186 +743,4 @@ describe('StoragePage', () => {
|
||||
expect(view.getByRole('button', { name: /storage.checkoutMonthly/ })).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: 'storage.redeemTitle' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows credit balance inside the credits dialog', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
|
||||
const creditsButton = view.getByLabelText('storage.viewCreditActivity')
|
||||
expect(creditsButton).toBeTruthy()
|
||||
expect(creditsButton.textContent).toContain('storage.creditsButton')
|
||||
expect(creditsButton.textContent).not.toContain('1,250')
|
||||
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
|
||||
expect(view.queryByText('storage.storageQuotaEntitlement')).toBeNull()
|
||||
expect(view.queryByText('storage.creditBalance')).toBeNull()
|
||||
expect(view.queryByText('1,250')).toBeNull()
|
||||
fireEvent.click(creditsButton)
|
||||
await waitFor(() => expect(view.getAllByText('storage.creditBalance').length).toBeGreaterThan(0))
|
||||
expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy()
|
||||
await waitFor(() => expect(view.getAllByText('1,250').length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
it('starts checkout from a credits top-up product', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudCreditProducts).mockResolvedValue({ items: [creditPackage()], total: 1 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy())
|
||||
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
|
||||
expect(await view.findByText('storage.creditTopUpTitle')).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.buyCredits' }))
|
||||
fireEvent.click(await view.findByRole('button', { name: 'storage.proceedToCheckout' }))
|
||||
|
||||
expect(openNewTab).toHaveBeenCalledWith(
|
||||
'/store/checkout?action=checkout&packageId=pkg-credits&priceId=price-credits-usd',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens credit activity dialog', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
|
||||
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
creditAccountId: 'credit-account-1',
|
||||
creditBucketId: 'credit-bucket-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
sourceType: 'gift_card_redemption',
|
||||
sourceId: 'gift-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
createdAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy())
|
||||
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
|
||||
|
||||
expect(await view.findByText('storage.creditActivityTitle')).toBeTruthy()
|
||||
expect(view.getAllByText('1,250').length).toBeGreaterThan(0)
|
||||
expect(view.getByText('storage.creditSourceGiftCard')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('redeems a gift card successfully', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(redeemCloudGiftCard).mockResolvedValue({
|
||||
redeemedCredits: 5000,
|
||||
entries: [],
|
||||
failures: [],
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
|
||||
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
|
||||
await waitFor(() => expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.redeemTitle' }))
|
||||
fireEvent.change(view.getByLabelText('storage.giftCardCode'), { target: { value: 'ZS-1234-5678' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.redeemAction' }))
|
||||
|
||||
await waitFor(() => expect(redeemCloudGiftCard).toHaveBeenCalledWith('ZS-1234-5678'))
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.redeemSuccess:5000')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'credits'] })
|
||||
})
|
||||
|
||||
it('continues payment for an unpaid order', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({
|
||||
items: [order({ id: 'order-unpaid', paymentStatus: 'unpaid', status: 'pending' })],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
|
||||
await waitFor(() => expect(view.getByLabelText('storage.continuePayment')).toBeTruthy())
|
||||
fireEvent.click(view.getByLabelText('storage.continuePayment'))
|
||||
|
||||
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=payment&orderId=order-unpaid')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'orders'] })
|
||||
})
|
||||
|
||||
it('cancels an unpaid order', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({
|
||||
items: [order({ id: 'order-unpaid', paymentStatus: 'unpaid', status: 'pending' })],
|
||||
total: 1,
|
||||
})
|
||||
vi.mocked(cancelCloudOrder).mockResolvedValue(
|
||||
order({ id: 'order-unpaid', status: 'canceled', paymentStatus: 'canceled' }),
|
||||
)
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
|
||||
await waitFor(() => expect(view.getByLabelText('storage.cancelOrder')).toBeTruthy())
|
||||
fireEvent.click(view.getByLabelText('storage.cancelOrder'))
|
||||
expect(await view.findByText('storage.cancelConfirm')).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: 'common.confirm' }))
|
||||
|
||||
await waitFor(() => expect(cancelCloudOrder).toHaveBeenCalledWith('order-unpaid'))
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.cancelSuccess')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'orders'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'credits'] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
import type { CloudProduct } from '@shared/types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckoutConfirmDialog, type CheckoutSelection } from '@/components/store/checkout-confirm-dialog'
|
||||
import { openCheckoutTab, resolveCheckoutSelection } from '@/components/store/checkout-navigation'
|
||||
import {
|
||||
CreditBalanceButton,
|
||||
CurrentPlanCard,
|
||||
FreeQuotaCard,
|
||||
StorageOrderHistoryDialog,
|
||||
StoragePackages,
|
||||
StorageUnavailableState,
|
||||
} from '@/components/store/storage-panels'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ApiError,
|
||||
cancelCloudOrder,
|
||||
getCloudCredits,
|
||||
getUserQuota,
|
||||
listCloudCreditLedgerEntries,
|
||||
listCloudCreditProducts,
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudStoreTargets,
|
||||
redeemCloudGiftCard,
|
||||
} from '@/lib/api'
|
||||
import { ApiError, getCloudCredits, getUserQuota, listCloudProducts, listCloudStoreTargets } from '@/lib/api'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
import { openNewTab } from '@/lib/browser-navigation'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/storage')({
|
||||
component: StoragePage,
|
||||
@@ -45,7 +21,6 @@ export function StoragePage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [checkoutRefreshActive, setCheckoutRefreshActive] = useState(false)
|
||||
const [cancelOrderId, setCancelOrderId] = useState<string | null>(null)
|
||||
const [checkoutSelection, setCheckoutSelection] = useState<CheckoutSelection | null>(null)
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const cloudStoreQuery = useQuery({
|
||||
@@ -65,12 +40,6 @@ export function StoragePage() {
|
||||
const currentTarget = targetsQuery.data?.items.find((item) => item.orgId === targetOrgId)
|
||||
const isTeamSpace = currentTarget?.type === 'team'
|
||||
const canManageBilling = targetsQuery.isSuccess && (!isTeamSpace || currentTarget?.role === 'owner')
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'orders', targetOrgId],
|
||||
queryFn: () => listCloudOrders(),
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
const quotaQuery = useQuery({
|
||||
queryKey: ['user', 'quota', targetOrgId],
|
||||
queryFn: getUserQuota,
|
||||
@@ -83,26 +52,7 @@ export function StoragePage() {
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
const creditProductsQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', 'products'],
|
||||
queryFn: listCloudCreditProducts,
|
||||
enabled: cloudStoreQuery.isSuccess,
|
||||
retry: false,
|
||||
})
|
||||
const creditLedgerQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', 'ledger-entries', targetOrgId],
|
||||
queryFn: listCloudCreditLedgerEntries,
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
const currentOrders = ordersQuery.data?.items ?? []
|
||||
const deliveredCheckoutCount = currentOrders.filter((order) => order.fulfillmentStatus === 'fulfilled').length
|
||||
const hasActiveSubscription = quotaQuery.data?.currentPlan?.subscription === true
|
||||
const credits = creditsQuery.data ? { balance: creditsQuery.data.balance } : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveredCheckoutCount > 0) queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
|
||||
}, [deliveredCheckoutCount, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutRefreshActive) return
|
||||
@@ -118,36 +68,8 @@ export function StoragePage() {
|
||||
}
|
||||
}, [checkoutRefreshActive, queryClient])
|
||||
|
||||
const cancelOrderMutation = useMutation({
|
||||
mutationFn: (orderId: string) => cancelCloudOrder(orderId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('storage.cancelSuccess'))
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const redeemMutation = useMutation({
|
||||
mutationFn: (code: string) => redeemCloudGiftCard(code),
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
t('storage.redeemSuccess', {
|
||||
amount: result.redeemedCredits,
|
||||
}),
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
function requestCheckout(packageId: string, priceId: string) {
|
||||
const products = [...(cloudStoreQuery.data?.items ?? []), ...(creditProductsQuery.data?.items ?? [])]
|
||||
const selection = resolveCheckoutSelection(products, packageId, priceId)
|
||||
const selection = resolveCheckoutSelection(cloudStoreQuery.data?.items ?? [], packageId, priceId)
|
||||
if (!selection) {
|
||||
startCheckout(packageId, priceId)
|
||||
return
|
||||
@@ -162,29 +84,10 @@ export function StoragePage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
}
|
||||
|
||||
function continuePayment(orderId: string) {
|
||||
openCheckoutTab({ action: 'payment', orderId })
|
||||
setCheckoutRefreshActive(true)
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
}
|
||||
|
||||
function managePlan() {
|
||||
openCheckoutTab({ action: 'portal' })
|
||||
}
|
||||
|
||||
function cancelOrder(orderId: string) {
|
||||
setCancelOrderId(orderId)
|
||||
}
|
||||
|
||||
function confirmCancelOrder() {
|
||||
if (!cancelOrderId) return
|
||||
cancelOrderMutation.mutate(cancelOrderId, {
|
||||
onSuccess: () => {
|
||||
setCancelOrderId(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (cloudStoreQuery.isLoading) {
|
||||
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
|
||||
}
|
||||
@@ -196,32 +99,9 @@ export function StoragePage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t('storage.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('storage.subtitle')}</p>
|
||||
</div>
|
||||
{canManageBilling && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<CreditBalanceButton
|
||||
credits={credits}
|
||||
products={creditProductsQuery.data?.items ?? []}
|
||||
entries={creditLedgerQuery.data?.items ?? []}
|
||||
loading={creditLedgerQuery.isLoading}
|
||||
onRedeem={(code) => redeemMutation.mutate(code)}
|
||||
onCheckout={requestCheckout}
|
||||
isRedeeming={redeemMutation.isPending}
|
||||
checkoutDisabled={!targetOrgId}
|
||||
/>
|
||||
<StorageOrderHistoryDialog
|
||||
orders={currentOrders}
|
||||
onContinuePayment={continuePayment}
|
||||
onCancelOrder={cancelOrder}
|
||||
continuingOrderId={null}
|
||||
cancelingOrderId={cancelOrderMutation.isPending ? cancelOrderMutation.variables : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t('storage.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('storage.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{checkoutRefreshActive && (
|
||||
@@ -257,22 +137,6 @@ export function StoragePage() {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={!!cancelOrderId} onOpenChange={(open) => !open && setCancelOrderId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('storage.cancelOrder')}</DialogTitle>
|
||||
<DialogDescription>{t('storage.cancelConfirm')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelOrderId(null)} disabled={cancelOrderMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmCancelOrder} disabled={cancelOrderMutation.isPending}>
|
||||
{cancelOrderMutation.isPending ? t('common.loading') : t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CheckoutConfirmDialog
|
||||
key={checkoutSelection?.priceId ?? 'none'}
|
||||
selection={checkoutSelection}
|
||||
@@ -284,41 +148,6 @@ export function StoragePage() {
|
||||
)
|
||||
}
|
||||
|
||||
type CheckoutTabInput =
|
||||
| { action: 'checkout'; packageId: string; priceId: string; promotionCode?: string }
|
||||
| { action: 'payment'; orderId: string }
|
||||
| { action: 'portal' }
|
||||
|
||||
function openCheckoutTab(input: CheckoutTabInput) {
|
||||
const search = new URLSearchParams({ action: input.action })
|
||||
if (input.action === 'checkout') {
|
||||
search.set('packageId', input.packageId)
|
||||
search.set('priceId', input.priceId)
|
||||
if (input.promotionCode) search.set('promotionCode', input.promotionCode)
|
||||
}
|
||||
if (input.action === 'payment') search.set('orderId', input.orderId)
|
||||
openNewTab(`/store/checkout?${search.toString()}`)
|
||||
}
|
||||
|
||||
function resolveCheckoutSelection(
|
||||
products: CloudProduct[],
|
||||
packageId: string,
|
||||
priceId: string,
|
||||
): CheckoutSelection | null {
|
||||
const product = products.find((item) => item.id === packageId)
|
||||
const price = product?.prices.find((item) => item.id === priceId)
|
||||
if (!product || !price) return null
|
||||
const interval = price.recurring?.interval
|
||||
return {
|
||||
packageId,
|
||||
priceId,
|
||||
productName: product.name,
|
||||
amount: price.amount,
|
||||
currency: price.currency,
|
||||
interval: interval === 'month' || interval === 'year' ? interval : null,
|
||||
}
|
||||
}
|
||||
|
||||
function isCloudStoreDisabledError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiError && error.reason === 'FEATURE_NOT_AVAILABLE' && error.metadata?.feature === 'quota_store'
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { CloudOrder, CloudProduct } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
cancelCloudOrder,
|
||||
getCloudCredits,
|
||||
listCloudCreditLedgerEntries,
|
||||
listCloudCreditProducts,
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudStoreTargets,
|
||||
redeemCloudGiftCard,
|
||||
} from '@/lib/api'
|
||||
import { openNewTab } from '@/lib/browser-navigation'
|
||||
import { WorkspaceBillingPage } from './billing'
|
||||
|
||||
const activeOrganization = vi.hoisted(() => ({
|
||||
value: { id: 'org-1' },
|
||||
}))
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: { amount?: string | number }) =>
|
||||
values?.amount === undefined ? key : `${key}:${values.amount}`,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (options: unknown) => options,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useActiveOrganization: () => ({ data: activeOrganization.value }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
openNewTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
ApiError: class MockApiError extends Error {},
|
||||
cancelCloudOrder: vi.fn(),
|
||||
getCloudCredits: vi.fn(),
|
||||
listCloudCreditLedgerEntries: vi.fn(),
|
||||
listCloudCreditProducts: vi.fn(),
|
||||
listCloudOrders: vi.fn(),
|
||||
listCloudProducts: vi.fn(),
|
||||
listCloudStoreTargets: vi.fn(),
|
||||
redeemCloudGiftCard: vi.fn(),
|
||||
createDiscountQuote: vi.fn(),
|
||||
}))
|
||||
|
||||
function creditProduct(): CloudProduct {
|
||||
return {
|
||||
id: 'credits-1',
|
||||
storeId: 'store-1',
|
||||
type: 'store_item',
|
||||
name: '5,000 Credits',
|
||||
description: null,
|
||||
metadata: { deliverable: { type: 'zpan.credits', credits: 5000 } },
|
||||
prices: [{ id: 'price-1', currency: 'usd', amount: 500, recurring: null }],
|
||||
active: true,
|
||||
sortOrder: 1,
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
updatedAt: '2026-05-05T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function unpaidOrder(): CloudOrder {
|
||||
return {
|
||||
id: 'order-unpaid',
|
||||
storeId: 'store-1',
|
||||
buyerAccountId: 'buyer-1',
|
||||
target: { orgId: 'org-1' },
|
||||
status: 'pending',
|
||||
subtotalAmount: 500,
|
||||
discountAmount: 0,
|
||||
totalAmount: 500,
|
||||
currency: 'usd',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
orderId: 'order-unpaid',
|
||||
productId: 'credits-1',
|
||||
productType: 'store_item',
|
||||
name: '5,000 Credits',
|
||||
description: null,
|
||||
quantity: 1,
|
||||
unitAmount: 500,
|
||||
totalAmount: 500,
|
||||
fulfillmentPayload: { deliverable: { type: 'zpan.credits', includedCredits: 5000 } },
|
||||
},
|
||||
],
|
||||
payments: [],
|
||||
paymentStatus: 'unpaid',
|
||||
fulfillmentStatus: 'pending',
|
||||
createdAt: '2026-05-05T00:00:00.000Z',
|
||||
paidAt: null,
|
||||
fulfilledAt: null,
|
||||
canceledAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceBillingPage />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
activeOrganization.value = { id: 'org-1' }
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudStoreTargets).mockResolvedValue({
|
||||
items: [{ orgId: 'org-1', name: 'Personal Space', type: 'personal', role: 'owner' }],
|
||||
total: 1,
|
||||
})
|
||||
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
|
||||
vi.mocked(listCloudCreditProducts).mockResolvedValue({ items: [creditProduct()], total: 1 })
|
||||
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
creditAccountId: 'account-1',
|
||||
creditBucketId: 'bucket-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
sourceType: 'gift_card_redemption',
|
||||
sourceId: 'gift-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
createdAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [unpaidOrder()], total: 1 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('WorkspaceBillingPage', () => {
|
||||
it('shows the Credits account and activity inline while keeping orders in a dialog', async () => {
|
||||
const view = renderPage()
|
||||
|
||||
expect(await view.findByText('1,250')).toBeTruthy()
|
||||
expect(view.queryByText('storage.billingTitle')).toBeNull()
|
||||
expect(view.getByText('storage.creditSourceGiftCard')).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'storage.historyTitle' })).toBeTruthy()
|
||||
expect(view.queryByText('5,000 Credits')).toBeNull()
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect(view.queryByText('org-1')).toBeNull()
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.historyTitle' }))
|
||||
expect(await view.findByRole('dialog')).toBeTruthy()
|
||||
expect(view.getByText('5,000 Credits')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('starts a Credits checkout and keeps gift-card redemption in a dialog', async () => {
|
||||
vi.mocked(redeemCloudGiftCard).mockResolvedValue({ redeemedCredits: 5000, entries: [], failures: [] })
|
||||
const view = renderPage()
|
||||
|
||||
fireEvent.click(await view.findByRole('button', { name: 'storage.creditTopUpTitle' }))
|
||||
expect(await view.findByRole('radio')).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.proceedToCheckout' }))
|
||||
fireEvent.click(await view.findByRole('button', { name: 'storage.proceedToCheckout' }))
|
||||
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=checkout&packageId=credits-1&priceId=price-1')
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.redeemTitle' }))
|
||||
fireEvent.change(view.getByLabelText('storage.giftCardCode'), { target: { value: 'ZS-1234-5678' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.redeemAction' }))
|
||||
|
||||
await waitFor(() => expect(redeemCloudGiftCard).toHaveBeenCalledWith('ZS-1234-5678'))
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.redeemSuccess:5000')
|
||||
})
|
||||
|
||||
it('continues and cancels an unpaid order', async () => {
|
||||
vi.mocked(cancelCloudOrder).mockResolvedValue({
|
||||
...unpaidOrder(),
|
||||
status: 'canceled',
|
||||
paymentStatus: 'canceled',
|
||||
})
|
||||
const view = renderPage()
|
||||
|
||||
fireEvent.click(await view.findByRole('button', { name: 'storage.historyTitle' }))
|
||||
fireEvent.click(await view.findByLabelText('storage.continuePayment'))
|
||||
expect(openNewTab).toHaveBeenCalledWith('/store/checkout?action=payment&orderId=order-unpaid')
|
||||
|
||||
fireEvent.click(view.getByLabelText('storage.cancelOrder'))
|
||||
fireEvent.click(await view.findByRole('button', { name: 'common.confirm' }))
|
||||
await waitFor(() => expect(cancelCloudOrder).toHaveBeenCalledWith('order-unpaid'))
|
||||
})
|
||||
|
||||
it('shows a read-only notice to non-owner team members', async () => {
|
||||
activeOrganization.value = { id: 'team-1' }
|
||||
vi.mocked(listCloudStoreTargets).mockResolvedValue({
|
||||
items: [{ orgId: 'team-1', name: 'Design Team', type: 'team', role: 'editor' }],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const view = renderPage()
|
||||
|
||||
expect(await view.findByText('storage.teamMemberBillingNotice')).toBeTruthy()
|
||||
expect(view.queryByText('storage.creditBalance')).toBeNull()
|
||||
expect(getCloudCredits).not.toHaveBeenCalled()
|
||||
expect(listCloudOrders).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckoutConfirmDialog, type CheckoutSelection } from '@/components/store/checkout-confirm-dialog'
|
||||
import { openCheckoutTab, resolveCheckoutSelection } from '@/components/store/checkout-navigation'
|
||||
import {
|
||||
CreditBillingPanel,
|
||||
StorageOrderHistoryDialog,
|
||||
StorageUnavailableState,
|
||||
} from '@/components/store/storage-panels'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ApiError,
|
||||
cancelCloudOrder,
|
||||
getCloudCredits,
|
||||
listCloudCreditLedgerEntries,
|
||||
listCloudCreditProducts,
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudStoreTargets,
|
||||
redeemCloudGiftCard,
|
||||
} from '@/lib/api'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/teams/$teamId/billing')({
|
||||
component: WorkspaceBillingPage,
|
||||
})
|
||||
|
||||
export function WorkspaceBillingPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const targetOrgId = activeOrg?.id ?? ''
|
||||
const [checkoutRefreshActive, setCheckoutRefreshActive] = useState(false)
|
||||
const [checkoutSelection, setCheckoutSelection] = useState<CheckoutSelection | null>(null)
|
||||
const [cancelOrderId, setCancelOrderId] = useState<string | null>(null)
|
||||
|
||||
const cloudStoreQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'packages'],
|
||||
queryFn: listCloudProducts,
|
||||
retry: false,
|
||||
})
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'targets'],
|
||||
queryFn: listCloudStoreTargets,
|
||||
enabled: cloudStoreQuery.isSuccess,
|
||||
retry: false,
|
||||
})
|
||||
const currentTarget = targetsQuery.data?.items.find((item) => item.orgId === targetOrgId)
|
||||
const isTeamSpace = currentTarget?.type === 'team'
|
||||
const canManageBilling = targetsQuery.isSuccess && (!isTeamSpace || currentTarget?.role === 'owner')
|
||||
const creditsQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', targetOrgId],
|
||||
queryFn: getCloudCredits,
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
const creditProductsQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', 'products'],
|
||||
queryFn: listCloudCreditProducts,
|
||||
enabled: cloudStoreQuery.isSuccess && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
const creditLedgerQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', 'ledger-entries', targetOrgId],
|
||||
queryFn: listCloudCreditLedgerEntries,
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'orders', targetOrgId],
|
||||
queryFn: () => listCloudOrders(),
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId && canManageBilling,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutRefreshActive) return
|
||||
const interval = window.setInterval(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
}, 5000)
|
||||
const timeout = window.setTimeout(() => setCheckoutRefreshActive(false), 120000)
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [checkoutRefreshActive, queryClient])
|
||||
|
||||
const redeemMutation = useMutation({
|
||||
mutationFn: (code: string) => redeemCloudGiftCard(code),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('storage.redeemSuccess', { amount: result.redeemedCredits }))
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
const cancelOrderMutation = useMutation({
|
||||
mutationFn: (orderId: string) => cancelCloudOrder(orderId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('storage.cancelSuccess'))
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
setCancelOrderId(null)
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
function requestCheckout(packageId: string, priceId: string) {
|
||||
const selection = resolveCheckoutSelection(creditProductsQuery.data?.items ?? [], packageId, priceId)
|
||||
if (!selection) {
|
||||
startCheckout(packageId, priceId)
|
||||
return
|
||||
}
|
||||
setCheckoutSelection(selection)
|
||||
}
|
||||
|
||||
function startCheckout(packageId: string, priceId: string, promotionCode?: string) {
|
||||
openCheckoutTab({ action: 'checkout', packageId, priceId, promotionCode })
|
||||
setCheckoutRefreshActive(true)
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
}
|
||||
|
||||
function continuePayment(orderId: string) {
|
||||
openCheckoutTab({ action: 'payment', orderId })
|
||||
setCheckoutRefreshActive(true)
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
}
|
||||
|
||||
if (cloudStoreQuery.isLoading) {
|
||||
return <p className="py-20 text-center text-muted-foreground">{t('common.loading')}</p>
|
||||
}
|
||||
|
||||
if (cloudStoreQuery.isError) {
|
||||
const disabled =
|
||||
cloudStoreQuery.error instanceof ApiError &&
|
||||
cloudStoreQuery.error.reason === 'FEATURE_NOT_AVAILABLE' &&
|
||||
cloudStoreQuery.error.metadata?.feature === 'quota_store'
|
||||
return <StorageUnavailableState disabled={disabled} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6 pb-8">
|
||||
{checkoutRefreshActive && (
|
||||
<div className="rounded-md border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('storage.checkoutPending')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManageBilling ? (
|
||||
<CreditBillingPanel
|
||||
credits={creditsQuery.data}
|
||||
products={creditProductsQuery.data?.items ?? []}
|
||||
entries={creditLedgerQuery.data?.items ?? []}
|
||||
loading={creditLedgerQuery.isLoading}
|
||||
onRedeem={(code) => redeemMutation.mutate(code)}
|
||||
onCheckout={requestCheckout}
|
||||
isRedeeming={redeemMutation.isPending}
|
||||
checkoutDisabled={!targetOrgId}
|
||||
accountAction={
|
||||
<StorageOrderHistoryDialog
|
||||
orders={ordersQuery.data?.items ?? []}
|
||||
onContinuePayment={continuePayment}
|
||||
onCancelOrder={setCancelOrderId}
|
||||
cancelingOrderId={cancelOrderMutation.isPending ? cancelOrderMutation.variables : null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
targetsQuery.isSuccess && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t('storage.teamMemberBillingNotice')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<Dialog open={!!cancelOrderId} onOpenChange={(open) => !open && setCancelOrderId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('storage.cancelOrder')}</DialogTitle>
|
||||
<DialogDescription>{t('storage.cancelConfirm')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelOrderId(null)} disabled={cancelOrderMutation.isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => cancelOrderId && cancelOrderMutation.mutate(cancelOrderId)}
|
||||
disabled={cancelOrderMutation.isPending}
|
||||
>
|
||||
{cancelOrderMutation.isPending ? t('common.loading') : t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CheckoutConfirmDialog
|
||||
key={checkoutSelection?.priceId ?? 'none'}
|
||||
selection={checkoutSelection}
|
||||
language={i18n.resolvedLanguage ?? 'en'}
|
||||
onOpenChange={(open) => !open && setCheckoutSelection(null)}
|
||||
onConfirm={startCheckout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,6 +88,7 @@ function TeamLayout() {
|
||||
},
|
||||
{ to: '/teams/$teamId/members', params: { teamId }, label: t('teams.tabMembers'), hidden: isPersonal },
|
||||
{ to: '/teams/$teamId/ihost', params: { teamId }, label: t('settings.tabImageHosting') },
|
||||
{ to: '/teams/$teamId/billing', params: { teamId }, label: t('teams.tabBilling') },
|
||||
{ to: '/teams/$teamId/activity', params: { teamId }, label: t('teams.tabActivity') },
|
||||
]
|
||||
const tabs = allTabs.filter((item) => !item.hidden)
|
||||
|
||||
Reference in New Issue
Block a user