Co-authored-by: Michael Chihlas <michael@resolutionflow.com> Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
268 lines
9.1 KiB
TypeScript
268 lines
9.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react'
|
|
|
|
import { billingApi } from '@/api/billing'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { useBillingStore } from '@/store/billingStore'
|
|
import { BillingPortalError } from '@/types/billing'
|
|
import { toast } from '@/lib/toast'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return '—'
|
|
return new Date(value).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
function statusLabel(status: string): string {
|
|
switch (status) {
|
|
case 'trialing':
|
|
return 'Trialing'
|
|
case 'active':
|
|
return 'Active'
|
|
case 'past_due':
|
|
return 'Past due'
|
|
case 'canceled':
|
|
return 'Canceled'
|
|
case 'incomplete':
|
|
return 'Incomplete'
|
|
case 'complimentary':
|
|
return 'Complimentary'
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
function statusToneClass(status: string): string {
|
|
switch (status) {
|
|
case 'active':
|
|
case 'complimentary':
|
|
return 'text-success'
|
|
case 'trialing':
|
|
return 'text-info'
|
|
case 'past_due':
|
|
case 'incomplete':
|
|
return 'text-warning'
|
|
case 'canceled':
|
|
return 'text-danger'
|
|
default:
|
|
return 'text-muted-foreground'
|
|
}
|
|
}
|
|
|
|
export function BillingPage() {
|
|
const subscription = useBillingStore((s) => s.subscription)
|
|
const planBilling = useBillingStore((s) => s.planBilling)
|
|
const isLoading = useBillingStore((s) => s.isLoading)
|
|
|
|
const [openingPortal, setOpeningPortal] = useState(false)
|
|
|
|
const status = subscription?.status ?? null
|
|
const isComplimentary = status === 'complimentary'
|
|
const isTrialing = status === 'trialing'
|
|
const isPastDue = status === 'past_due'
|
|
const isCanceled = status === 'canceled'
|
|
|
|
const handleOpenPortal = async () => {
|
|
setOpeningPortal(true)
|
|
try {
|
|
const { url } = await billingApi.getPortalSession()
|
|
window.location.href = url
|
|
} catch (err) {
|
|
if (err instanceof BillingPortalError) {
|
|
if (err.code === 'no_stripe_customer') {
|
|
toast.error('Complete checkout first to access billing portal.')
|
|
} else {
|
|
toast.error('Billing portal is not available right now.')
|
|
}
|
|
} else {
|
|
toast.error('Failed to open billing portal.')
|
|
}
|
|
setOpeningPortal(false)
|
|
}
|
|
}
|
|
|
|
if (isLoading && !subscription) {
|
|
return (
|
|
<div className="flex justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageMeta title="Billing" />
|
|
<div>
|
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
|
Billing
|
|
</h1>
|
|
</div>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Manage your subscription, payment method, and billing history.
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Past-due banner ────────────────────────────────────────────── */}
|
|
{isPastDue && (
|
|
<div
|
|
data-testid="past-due-banner"
|
|
className={cn(
|
|
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
|
|
'bg-warning-dim p-4 text-foreground',
|
|
)}
|
|
>
|
|
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium">Your last payment failed.</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Update your payment method to keep access to ResolutionFlow.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
loading={openingPortal}
|
|
onClick={handleOpenPortal}
|
|
data-testid="past-due-update-payment"
|
|
>
|
|
Update payment method
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Subscription summary card ──────────────────────────────────── */}
|
|
<div className="card-flat max-w-xl space-y-5 p-6">
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Crown className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium text-foreground">
|
|
{planBilling?.display_name ?? 'No active plan'}
|
|
</span>
|
|
</div>
|
|
{subscription && (
|
|
<div
|
|
className={cn(
|
|
'mt-1 text-xs',
|
|
statusToneClass(subscription.status),
|
|
)}
|
|
>
|
|
{statusLabel(subscription.status)}
|
|
{subscription.cancel_at_period_end && ' · cancels at period end'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{subscription?.seat_limit != null && (
|
|
<div className="text-right">
|
|
<div className="text-xs text-muted-foreground">Seats</div>
|
|
<div className="text-sm tabular-nums text-foreground">
|
|
{subscription.seat_limit}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
|
|
<div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
|
|
</div>
|
|
<div className="text-sm tabular-nums text-foreground">
|
|
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-muted-foreground">Plan started</div>
|
|
<div className="text-sm tabular-nums text-foreground">
|
|
{formatDate(subscription?.current_period_start)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* State-specific messaging ------------------------------------ */}
|
|
{isComplimentary && (
|
|
<div
|
|
data-testid="complimentary-message"
|
|
className="rounded-md bg-success-dim p-3 text-xs text-success"
|
|
>
|
|
Complimentary Pro — no billing required.
|
|
</div>
|
|
)}
|
|
|
|
{isTrialing && (
|
|
<div
|
|
data-testid="trial-message"
|
|
className="rounded-md bg-info-dim p-3 text-xs text-info"
|
|
>
|
|
Trial ends {formatDate(subscription?.current_period_end)} — pick a plan
|
|
to continue.
|
|
</div>
|
|
)}
|
|
|
|
{isCanceled && (
|
|
<div
|
|
data-testid="canceled-message"
|
|
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
|
|
>
|
|
Subscription canceled. Reactivate by picking a plan.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Actions ────────────────────────────────────────────────────── */}
|
|
{!isComplimentary && (
|
|
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
|
|
{(isTrialing || isCanceled) && (
|
|
<Link
|
|
to="/account/billing/select-plan"
|
|
data-testid="select-plan-link"
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
|
|
'text-sm font-semibold text-white hover:brightness-110',
|
|
)}
|
|
>
|
|
Pick a plan
|
|
</Link>
|
|
)}
|
|
|
|
{!isTrialing && !isCanceled && (
|
|
<Link
|
|
to="/account/billing/select-plan"
|
|
data-testid="change-plan-link"
|
|
className={cn(
|
|
'inline-flex items-center gap-2 rounded-lg border border-border',
|
|
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
|
'hover:border-border-hover',
|
|
)}
|
|
>
|
|
Change plan
|
|
</Link>
|
|
)}
|
|
|
|
<Button
|
|
variant="secondary"
|
|
loading={openingPortal}
|
|
onClick={handleOpenPortal}
|
|
data-testid="manage-billing-button"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
Manage billing
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default BillingPage
|