351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
import { useState } from "react";
|
|
import { ExternalLink } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Muted } from "@/components/ui/typography";
|
|
import { CopyableEmail } from "@/components/copyable-email";
|
|
import { cn } from "@/lib/utils";
|
|
import { toUserMessage } from "@/lib/errors";
|
|
import { SUPPORT_EMAIL } from "@/lib/constants";
|
|
import {
|
|
useCreateCheckoutSession,
|
|
useCreatePortalSession,
|
|
useNetworkBilling,
|
|
} from "@/hooks/use-billing";
|
|
import { useNetworkUsage } from "@/hooks/use-network-usage";
|
|
import { useIsNetworkAdmin } from "@/hooks/use-networks";
|
|
import type { BillingCadence, BillingStatus } from "@/api/types";
|
|
|
|
function formatCents(cents: number): string {
|
|
if (cents % 100 === 0) return `$${cents / 100}`;
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
function formatDate(date: Date): string {
|
|
return date.toLocaleDateString(undefined, {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
|
|
if (status === "past_due")
|
|
return <Badge variant="destructive">Past due</Badge>;
|
|
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
|
|
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
|
|
return null;
|
|
}
|
|
|
|
function InfoRow({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: React.ReactNode;
|
|
value: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
|
<Muted className="text-sm">{label}</Muted>
|
|
<div className="flex-1" />
|
|
<div className="text-sm">{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CadenceOption({
|
|
value,
|
|
label,
|
|
perSeatCents,
|
|
billedNote,
|
|
saveBadge,
|
|
selected,
|
|
}: {
|
|
value: BillingCadence;
|
|
label: string;
|
|
perSeatCents: number;
|
|
billedNote: string;
|
|
saveBadge?: string;
|
|
selected: boolean;
|
|
}) {
|
|
return (
|
|
<Label
|
|
htmlFor={`cadence-${value}`}
|
|
className={cn(
|
|
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors",
|
|
selected && "bg-accent/50",
|
|
)}
|
|
>
|
|
<RadioGroupItem id={`cadence-${value}`} value={value} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-sm font-medium">{label}</p>
|
|
{saveBadge && <Badge>{saveBadge}</Badge>}
|
|
</div>
|
|
<Muted className="text-xs">{billedNote}</Muted>
|
|
</div>
|
|
<div className="shrink-0 text-right">
|
|
<p className="text-sm font-medium">{formatCents(perSeatCents)}</p>
|
|
<Muted className="text-xs">per seat / mo</Muted>
|
|
</div>
|
|
</Label>
|
|
);
|
|
}
|
|
|
|
function formatResetLocal(resetAt: Date): string {
|
|
const time = resetAt.toLocaleTimeString(undefined, {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
const now = new Date();
|
|
const isSameDay =
|
|
resetAt.getFullYear() === now.getFullYear() &&
|
|
resetAt.getMonth() === now.getMonth() &&
|
|
resetAt.getDate() === now.getDate();
|
|
return `${isSameDay ? "today" : "tomorrow"} at ${time}`;
|
|
}
|
|
|
|
/**
|
|
* Read-only plan + quota summary, sourced from `/usage` (member-accessible).
|
|
* The `/billing` endpoint is admin-gated, so we can't use it for the
|
|
* everyone-visible summary.
|
|
*/
|
|
function PlanSummary({ networkId }: { networkId: string }) {
|
|
const { data: usage } = useNetworkUsage(networkId);
|
|
|
|
if (!usage) return null;
|
|
|
|
const isPro = usage.plan === "pro";
|
|
|
|
return (
|
|
<>
|
|
<InfoRow
|
|
label="Plan"
|
|
value={
|
|
<div className="flex items-center gap-2">
|
|
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
|
|
<Badge variant={isPro ? "default" : "secondary"}>
|
|
{isPro ? "Pro" : "Free"}
|
|
</Badge>
|
|
</div>
|
|
}
|
|
/>
|
|
{!isPro && (
|
|
<>
|
|
<Separator className="mx-4" />
|
|
<InfoRow
|
|
label="Today's messages"
|
|
value={
|
|
usage && usage.limit != null ? (
|
|
<div className="flex flex-col items-end">
|
|
<span className="tabular-nums">
|
|
{usage.used} / {usage.limit}
|
|
</span>
|
|
<Muted className="text-xs">
|
|
Resets {formatResetLocal(usage.reset_at)}
|
|
</Muted>
|
|
</div>
|
|
) : (
|
|
<Muted className="text-sm">—</Muted>
|
|
)
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function FreeBilling({
|
|
networkId,
|
|
billing,
|
|
}: {
|
|
networkId: string;
|
|
billing: BillingStatus;
|
|
}) {
|
|
const createCheckout = useCreateCheckoutSession(networkId);
|
|
const [cadence, setCadence] = useState<BillingCadence>("annual");
|
|
|
|
const handleUpgrade = () => {
|
|
createCheckout.mutate(cadence, {
|
|
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
|
});
|
|
};
|
|
|
|
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
|
|
const savingsPct = Math.round(
|
|
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<RadioGroup
|
|
value={cadence}
|
|
onValueChange={(v) => setCadence(v as BillingCadence)}
|
|
className="gap-0"
|
|
>
|
|
<CadenceOption
|
|
value="annual"
|
|
label="Annual"
|
|
perSeatCents={annualPerSeatMonthlyCents}
|
|
billedNote="Billed annually"
|
|
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
|
|
selected={cadence === "annual"}
|
|
/>
|
|
<Separator className="mx-4" />
|
|
<CadenceOption
|
|
value="monthly"
|
|
label="Monthly"
|
|
perSeatCents={billing.price_monthly_cents}
|
|
billedNote="Billed monthly · cancel anytime"
|
|
selected={cadence === "monthly"}
|
|
/>
|
|
</RadioGroup>
|
|
<div className="px-4 py-3">
|
|
<Button
|
|
className="w-full"
|
|
onClick={handleUpgrade}
|
|
disabled={createCheckout.isPending}
|
|
>
|
|
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ProBilling({
|
|
networkId,
|
|
billing,
|
|
}: {
|
|
networkId: string;
|
|
billing: BillingStatus;
|
|
}) {
|
|
const createPortal = useCreatePortalSession(networkId);
|
|
|
|
const handleManage = () => {
|
|
createPortal.mutate(undefined, {
|
|
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
|
});
|
|
};
|
|
|
|
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly";
|
|
const perSeatCents =
|
|
billing.cadence === "annual"
|
|
? Math.round(billing.price_annual_cents / 12)
|
|
: billing.price_monthly_cents;
|
|
const renewal = billing.current_period_end
|
|
? formatDate(billing.current_period_end)
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
{billing.cancel_at_period_end && renewal && (
|
|
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
|
Your subscription is set to downgrade to Free on {renewal}.
|
|
</div>
|
|
)}
|
|
{billing.plan_status === "past_due" && (
|
|
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
|
Your last payment failed. Update your payment method to keep Pro
|
|
active.
|
|
</div>
|
|
)}
|
|
|
|
<InfoRow
|
|
label="Billing"
|
|
value={
|
|
<div className="flex items-center gap-2">
|
|
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
|
|
<PlanStatusBadge status={billing.plan_status} />
|
|
</div>
|
|
}
|
|
/>
|
|
<Separator className="mx-4" />
|
|
<InfoRow label="Seats" value={billing.seats} />
|
|
{renewal && (
|
|
<>
|
|
<Separator className="mx-4" />
|
|
<InfoRow
|
|
label={billing.cancel_at_period_end ? "Ends" : "Renews"}
|
|
value={renewal}
|
|
/>
|
|
</>
|
|
)}
|
|
<div className="px-4 py-3">
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={handleManage}
|
|
disabled={createPortal.isPending}
|
|
>
|
|
<ExternalLink className="mr-2 size-3.5" />
|
|
{createPortal.isPending
|
|
? "Opening Stripe..."
|
|
: "Manage subscription"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Unified billing section. Shows the plan + usage summary to every member,
|
|
* and the admin-only management controls (upgrade / portal) below.
|
|
*
|
|
* `/billing` is admin-gated, so the management controls are the only part
|
|
* that depends on it — members rely on `/usage` for the summary.
|
|
*/
|
|
export function BillingSection({ networkId }: { networkId: string }) {
|
|
const isAdmin = useIsNetworkAdmin(networkId);
|
|
return (
|
|
<>
|
|
<PlanSummary networkId={networkId} />
|
|
{isAdmin && (
|
|
<>
|
|
<Separator className="mx-4" />
|
|
<AdminBillingControls networkId={networkId} />
|
|
<Separator className="mx-4" />
|
|
<InfoRow
|
|
label={
|
|
<span className="flex flex-col">
|
|
<span>Billing support</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
Invoices, receipts, or plan changes
|
|
</span>
|
|
</span>
|
|
}
|
|
value={<CopyableEmail email={SUPPORT_EMAIL} />}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AdminBillingControls({ networkId }: { networkId: string }) {
|
|
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
|
|
|
|
if (isLoading || !billing) {
|
|
return (
|
|
<div className="px-4 py-3">
|
|
<Muted className="text-sm">Loading billing...</Muted>
|
|
</div>
|
|
);
|
|
}
|
|
if (error) {
|
|
return (
|
|
<div className="px-4 py-3">
|
|
<Muted className="text-sm">Couldn't load billing: {toUserMessage(error)}</Muted>
|
|
</div>
|
|
);
|
|
}
|
|
if (billing.plan === "pro") {
|
|
return <ProBilling networkId={networkId} billing={billing} />;
|
|
}
|
|
return <FreeBilling networkId={networkId} billing={billing} />;
|
|
}
|