'use client'; //=========================================== // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template //=========================================== import { KnownErrors } from "@hexclave/shared"; import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { ActionDialog, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Separator, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, toast, Typography } from "@hexclave/ui"; import { CardElement, Elements, useElements, useStripe } from "@stripe/react-stripe-js"; import { loadStripe } from "@stripe/stripe-js"; import { useMemo, useState } from "react"; import { useStackApp } from "../../.."; import { envVars } from "../../../generated/env"; import { useTranslation } from "../../../lib/translations"; import { Section } from "../section"; import { Result } from "@hexclave/shared/dist/utils/results"; import type { CustomerInvoiceStatus, CustomerInvoicesList, CustomerInvoicesListOptions } from "../../../lib/hexclave-app/customers"; type PaymentMethodSummary = { id: string, brand: string | null, last4: string | null, exp_month: number | null, exp_year: number | null, } | null; function formatPaymentMethod(pm: NonNullable) { const details = [ pm.brand ? pm.brand.toUpperCase() : null, pm.last4 ? `•••• ${pm.last4}` : null, pm.exp_month && pm.exp_year ? `exp ${pm.exp_month}/${pm.exp_year}` : null, ].filter(Boolean); return details.join(" · "); } const formatInvoiceStatus = (status: CustomerInvoiceStatus, t: (value: string) => string) => { if (!status) { return t("Unknown"); } switch (status) { case "draft": { return t("Draft"); } case "open": { return t("Open"); } case "paid": { return t("Paid"); } case "uncollectible": { return t("Uncollectible"); } case "void": { return t("Void"); } default: { return t("Unknown"); } } }; const formatInvoiceAmount = (amountTotal: number | null | undefined, t: (value: string) => string) => { if (typeof amountTotal !== "number" || Number.isNaN(amountTotal)) { return t("Unknown"); } const normalized = amountTotal / 100; const formatted = new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(normalized); return `$${formatted}`; }; const formatInvoiceDate = (date: Date | null | undefined, t: (value: string) => string) => { if (!date || Number.isNaN(date.getTime())) { return t("Unknown"); } return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(date); }; type CustomerBilling = { hasCustomer: boolean, defaultPaymentMethod: PaymentMethodSummary, }; type CustomerPaymentMethodSetupIntent = { clientSecret: string, stripeAccountId: string, }; type CustomerLike = { id: string, useBilling: () => CustomerBilling, useProducts: () => Array<{ id: string | null, quantity: number, displayName: string, customerType: "user" | "team" | "custom", type?: "one_time" | "subscription", switchOptions?: Array<{ productId: string, displayName: string, prices: Record, }>, subscription: null | { subscriptionId: string | null, currentPeriodEnd: Date | null, cancelAtPeriodEnd: boolean, isCancelable: boolean, }, }>, useInvoices: (options?: CustomerInvoicesListOptions) => CustomerInvoicesList, createPaymentMethodSetupIntent: () => Promise, setDefaultPaymentMethodFromSetupIntent: (setupIntentId: string) => Promise, switchSubscription: (options: { fromProductId: string, toProductId: string, priceId?: string, quantity?: number }) => Promise, }; function SetDefaultPaymentMethodForm(props: { clientSecret: string, onSetupIntentSucceeded: (setupIntentId: string) => Promise, }) { const stripe = useStripe(); const elements = useElements(); const [errorMessage, setErrorMessage] = useState(null); const darkMode = "color-scheme" in document.documentElement.style && document.documentElement.style["color-scheme"] === "dark"; return (
Card details
{errorMessage && ( {errorMessage} )}
); } export function PaymentsPanel(props: { title?: string, customer?: CustomerLike, customerType?: "user" | "team", mockMode?: boolean, }) { if (props.mockMode) { return ; } if (!props.customer) { return null; } return ; } function MockPaymentsPanel(props: { title?: string }) { const { t } = useTranslation(); const defaultPaymentMethod: PaymentMethodSummary = { id: "pm_mock", brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030, }; return (
{props.title && {props.title}}
{formatPaymentMethod(defaultPaymentMethod)}
{t("Pro")} {t("Renews on")} Jan 1, 2030
{t("Credits pack")} {t("One-time purchase")}
); } function RealPaymentsPanel(props: { title?: string, customer: CustomerLike, customerType: "user" | "team" }) { const { t } = useTranslation(); const hexclaveApp = useStackApp(); const billing = props.customer.useBilling(); const defaultPaymentMethod = billing.defaultPaymentMethod; const products = props.customer.useProducts(); const invoices = props.customer.useInvoices({ limit: 10 }); const productsForCustomerType = products.filter(product => product.customerType === props.customerType); const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); const [setupIntentClientSecret, setSetupIntentClientSecret] = useState(null); const [setupIntentStripeAccountId, setSetupIntentStripeAccountId] = useState(null); const [cancelTarget, setCancelTarget] = useState<{ productId: string, subscriptionId?: string } | null>(null); const [switchFromProductId, setSwitchFromProductId] = useState(null); const [switchToProductId, setSwitchToProductId] = useState(null); const stripePromise = useMemo(() => { if (!setupIntentStripeAccountId) return null; const publishableKey = envVars.HEXCLAVE_STRIPE_PUBLISHABLE_KEY; if (!publishableKey) return null; return loadStripe(publishableKey, { stripeAccount: setupIntentStripeAccountId }); }, [setupIntentStripeAccountId]); const handleAsyncError = (error: unknown) => { if (error instanceof KnownErrors.DefaultPaymentMethodRequired) { toast({ title: t("No default payment method"), description: t("Add a payment method before switching plans."), variant: "destructive", }); return; } alert(`An unhandled error occurred. Please ${envVars.NODE_ENV === "development" ? "check the browser console for the full error." : "report this to the developer."}\n\n${error}`); }; const openPaymentDialog = () => { runAsynchronously(async () => { setPaymentDialogOpen(true); const res = await props.customer.createPaymentMethodSetupIntent(); setSetupIntentClientSecret(res.clientSecret); setSetupIntentStripeAccountId(res.stripeAccountId); }, { onError: handleAsyncError }); }; const closePaymentDialog = () => { setPaymentDialogOpen(false); setSetupIntentClientSecret(null); setSetupIntentStripeAccountId(null); }; const openSwitchDialog = (productId: string, firstOptionId: string | null) => { setSwitchFromProductId(productId); setSwitchToProductId(firstOptionId); }; const closeSwitchDialog = () => { setSwitchFromProductId(null); setSwitchToProductId(null); }; const switchSourceProduct = switchFromProductId ? productsForCustomerType.find((product) => product.id === switchFromProductId) ?? null : null; const switchOptions = switchSourceProduct?.switchOptions ?? []; const selectedSwitchOption = switchOptions.find((option) => option.productId === switchToProductId) ?? null; const selectedPriceId = selectedSwitchOption ? (Object.keys(selectedSwitchOption.prices)[0] ?? null) : null; return (
{props.title && {props.title}} {defaultPaymentMethod && (
{formatPaymentMethod(defaultPaymentMethod)} { if (!open) { closePaymentDialog(); } else { setPaymentDialogOpen(true); } }} title={t("Update payment method")} > {!setupIntentClientSecret || !setupIntentStripeAccountId || !stripePromise ? ( ) : ( { await props.customer.setDefaultPaymentMethodFromSetupIntent(setupIntentId); closePaymentDialog(); }} /> )}
)} {productsForCustomerType.length > 0 && (
{productsForCustomerType.map((product, index) => { const quantitySuffix = product.quantity !== 1 ? ` ×${product.quantity}` : ""; const isSubscription = product.type === "subscription"; const isCancelable = isSubscription && !!product.subscription?.isCancelable; const canSwitchPlans = isSubscription && defaultPaymentMethod && !!product.id && (product.switchOptions?.length ?? 0) > 0; const renewsAt = isSubscription ? (product.subscription?.currentPeriodEnd ?? null) : null; const subtitle = product.type === "one_time" ? t("One-time purchase") : renewsAt ? `${t("Renews on")} ${new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(renewsAt)}` : t("Subscription"); return (
{product.displayName}{quantitySuffix} {subtitle}
{canSwitchPlans && ( )} {isCancelable && ( )}
); })}
{ if (!open) setCancelTarget(null); }} title={t("Cancel subscription")} description={t("Canceling will stop future renewals for this subscription.")} danger cancelButton okButton={{ label: t("Cancel subscription"), onClick: async () => { if (!cancelTarget) return; const { productId, subscriptionId } = cancelTarget; if (props.customerType === "team") { await hexclaveApp.cancelSubscription({ teamId: props.customer.id, productId, subscriptionId }); } else { await hexclaveApp.cancelSubscription({ productId, subscriptionId }); } setCancelTarget(null); }, }} /> { if (!open) closeSwitchDialog(); }} title={t("Change plan")} description={t("Select a new plan from the same product line.")} cancelButton okButton={{ label: t("Switch plan"), onClick: async () => { const fromProductId = switchFromProductId; const toProductId = switchToProductId; if (!fromProductId || !toProductId) return; if (!selectedPriceId) return; const result = await Result.fromThrowingAsync(() => props.customer.switchSubscription({ fromProductId, toProductId, priceId: selectedPriceId, })); if (result.status === "error") { handleAsyncError(result.error); return "prevent-close"; } closeSwitchDialog(); }, props: { disabled: !switchFromProductId || !switchToProductId || !selectedPriceId, }, }} >
{switchOptions.length === 0 ? ( {t("No other plans available for this subscription.")} ) : ( <> {t("Choose a plan")} )}
) } {invoices.length > 0 && ( <>
{t("Invoices")} {t("Review past invoices and receipts.")}
{t("Date")} {t("Status")} {t("Amount")} {t("Invoice")} {invoices.map((invoice, index) => { const createdAtTime = invoice.createdAt.getTime(); const invoiceKey = Number.isNaN(createdAtTime) ? `invoice-${index}` : `invoice-${createdAtTime}-${index}`; return ( {formatInvoiceDate(invoice.createdAt, t)} {formatInvoiceStatus(invoice.status, t)} {formatInvoiceAmount(invoice.amountTotal, t)} {invoice.hostedInvoiceUrl ? ( ) : ( {t("Unavailable")} )} ); })}
)}
); }