cleanup billing management

This commit is contained in:
talksik
2026-04-14 13:01:09 -07:00
parent 2cd80a51bc
commit 3f5d55769c
6 changed files with 224 additions and 269 deletions
-2
View File
@@ -14,7 +14,6 @@ import NetworkRoot from "@/features/network-root";
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
import Layout from "@/features/layout";
import NetworkSettingsPage from "@/features/network-settings";
import NetworkBillingPage from "@/features/network-billing";
import { Toaster } from "@/components/ui/sonner";
import { PusherProvider } from "@/lib/pusher-provider";
@@ -72,7 +71,6 @@ function AuthenticatedApp() {
<Route path=":networkId">
<Route index element={<Layout><NetworkRoot /></Layout>} />
<Route path="settings" element={<NetworkSettingsPage />} />
<Route path="settings/billing" element={<NetworkBillingPage />} />
<Route path="*" element={<ParticleViewResolver />} />
</Route>
</Route>
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+170 -245
View File
@@ -1,27 +1,18 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check, ExternalLink } from "lucide-react";
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
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 { WindowControls } from "@/components/window-controls";
import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils";
import {
useCreateCheckoutSession,
useCreatePortalSession,
useNetworkBilling,
} from "@/hooks/use-billing";
import { useAuthStore } from "@/stores/auth-store";
import type { BillingCadence, BillingStatus } from "@/api/types";
function formatCents(cents: number): string {
@@ -38,75 +29,69 @@ function formatDate(date: Date): string {
}
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
if (status === "past_due") {
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>;
}
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
return null;
}
function PricingCard({
cadence,
pricePerSeatCents,
seats,
saveBadge,
billedNote,
onUpgrade,
isLoading,
function InfoRow({
label,
value,
}: {
cadence: BillingCadence;
pricePerSeatCents: number;
seats: number;
saveBadge?: string;
billedNote: string;
onUpgrade: () => void;
isLoading: boolean;
label: React.ReactNode;
value: React.ReactNode;
}) {
const label = cadence === "monthly" ? "Monthly" : "Annual";
const perSeat = formatCents(pricePerSeatCents);
const total = formatCents(pricePerSeatCents * seats);
return (
<Card className="flex-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{label}
{saveBadge && (
<Badge variant="default" className="font-medium">
{saveBadge}
</Badge>
)}
</CardTitle>
<CardDescription>{billedNote}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline gap-1">
<span className="text-2xl font-semibold">{perSeat}</span>
<Muted className="text-xs">/ seat / month</Muted>
</div>
<Muted className="text-xs">
{total} / month for {seats} {seats === 1 ? "seat" : "seats"}
</Muted>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={onUpgrade}
disabled={isLoading}
>
{isLoading ? "Opening Stripe..." : "Upgrade"}
</Button>
</CardFooter>
</Card>
<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 FreePlanView({
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 FreeBilling({
networkId,
billing,
}: {
@@ -114,75 +99,68 @@ function FreePlanView({
billing: BillingStatus;
}) {
const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>("annual");
const handleUpgrade = (cadence: BillingCadence) => {
const handleUpgrade = () => {
createCheckout.mutate(cadence, {
onSuccess: ({ url }) => {
window.electronLink.openExternal(url);
},
onError: (err) => {
toast.error(err.message || "Failed to start checkout");
},
onSuccess: ({ url }) => window.electronLink.openExternal(url),
onError: (err) => toast.error(err.message || "Failed to start checkout"),
});
};
const features = [
"Unlimited members",
"Priority support",
"All current and future features",
];
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
const savingsPct = Math.round(
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
);
return (
<div className="space-y-4 px-4 pb-6 pt-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Llink Free</CardTitle>
<CardDescription>
Current plan · up to 50 messages per day
</CardDescription>
</div>
<>
<InfoRow
label="Plan"
value={
<div className="flex items-center gap-2">
<span>Llink Free</span>
<Badge variant="secondary">Free</Badge>
</div>
</CardHeader>
</Card>
<div>
<p className="text-muted-foreground mb-2 px-1 text-xs font-medium uppercase tracking-wider">
Upgrade to Pro
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<PricingCard
cadence="monthly"
pricePerSeatCents={billing.price_monthly_cents}
seats={billing.seats}
billedNote="Billed monthly · cancel anytime"
onUpgrade={() => handleUpgrade("monthly")}
isLoading={createCheckout.isPending}
/>
<PricingCard
cadence="annual"
pricePerSeatCents={annualPerSeatMonthlyCents}
seats={billing.seats}
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
billedNote="Billed annually"
onUpgrade={() => handleUpgrade("annual")}
isLoading={createCheckout.isPending}
/>
</div>
}
/>
<Separator className="mx-4" />
<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>
</div>
</>
);
}
function ProPlanView({
function ProBilling({
networkId,
billing,
}: {
@@ -193,17 +171,13 @@ function ProPlanView({
const handleManage = () => {
createPortal.mutate(undefined, {
onSuccess: ({ url }) => {
window.electronLink.openExternal(url);
},
onError: (err) => {
toast.error(err.message || "Failed to open billing portal");
},
onSuccess: ({ url }) => window.electronLink.openExternal(url),
onError: (err) =>
toast.error(err.message || "Failed to open billing portal"),
});
};
const cadenceLabel =
billing.cadence === "annual" ? "Annual" : "Monthly";
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly";
const perSeatCents =
billing.cadence === "annual"
? Math.round(billing.price_annual_cents / 12)
@@ -213,130 +187,81 @@ function ProPlanView({
: null;
return (
<div className="space-y-4 px-4 pb-6 pt-4">
<>
{billing.cancel_at_period_end && renewal && (
<div className="border-destructive/30 bg-destructive/10 text-destructive rounded-lg border px-4 py-3 text-sm">
Your subscription is set to downgrade to Free on {renewal}. You can
reactivate from the billing portal before then.
<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 rounded-lg border px-4 py-3 text-sm">
Your last payment failed. Update your payment method in the billing
portal to keep Pro features active.
<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>
)}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
Llink Pro
<PlanStatusBadge status={billing.plan_status} />
</CardTitle>
<CardDescription>
{cadenceLabel} · {formatCents(perSeatCents)} per seat / month
</CardDescription>
</div>
<InfoRow
label="Plan"
value={
<div className="flex items-center gap-2">
<span>Llink Pro</span>
<PlanStatusBadge status={billing.plan_status} />
<Badge>Pro</Badge>
</div>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-y-3 text-sm">
<dt className="text-muted-foreground">Seats</dt>
<dd className="text-right">{billing.seats}</dd>
<dt className="text-muted-foreground">
{billing.cancel_at_period_end ? "Ends" : "Renews"}
</dt>
<dd className="text-right">{renewal ?? "—"}</dd>
</dl>
</CardContent>
<CardFooter>
<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>
</CardFooter>
</Card>
<Muted className="px-1 text-xs">
Seats are synced automatically when you add or remove members. Changes
are prorated.
</Muted>
</div>
);
}
export default function NetworkBillingPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const { data: billing, isLoading, error } = useNetworkBilling(
isAdmin ? networkId : undefined,
);
const networkName = network?.name ?? "Network";
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
}
/>
<Separator className="mx-4" />
<InfoRow
label="Billing"
value={`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
/>
<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="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate(`/${networkId}/settings`)}
variant="outline"
className="w-full"
onClick={handleManage}
disabled={createPortal.isPending}
>
<ArrowLeft className="size-3.5" />
<ExternalLink className="mr-2 size-3.5" />
{createPortal.isPending
? "Opening Stripe..."
: "Manage subscription"}
</Button>
<span className="text-sm font-medium">{networkName} · Billing</span>
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
{!isAdmin ? (
<div className="px-4 py-6">
<Muted className="text-sm">
Only the network admin can manage billing.
</Muted>
</div>
) : isLoading || !billing ? (
<div className="px-4 py-6">
<Muted className="text-sm">Loading billing...</Muted>
</div>
) : error ? (
<div className="px-4 py-6">
<Muted className="text-sm">
Failed to load billing. Try again later.
</Muted>
</div>
) : billing.plan === "pro" ? (
<ProPlanView networkId={networkId!} billing={billing} />
) : (
<FreePlanView networkId={networkId!} billing={billing} />
)}
<Separator />
<div className="px-4 py-4">
<Muted className="text-xs">
Payments are processed securely by Stripe. You can view invoices
and update payment methods from the subscription portal.
</Muted>
</div>
</ScrollArea>
</div>
</>
);
}
export function BillingSection({ 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">Failed to load billing.</Muted>
</div>
);
}
if (billing.plan === "pro") {
return <ProBilling networkId={networkId} billing={billing} />;
}
return <FreeBilling networkId={networkId} billing={billing} />;
}
+2 -21
View File
@@ -1,16 +1,13 @@
import { useCallback, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { CircleDot, CircleCheckBig, CreditCard } from "lucide-react";
import { CircleDot, CircleCheckBig } from "lucide-react";
import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import { VideoAudioToggle } from "@/components/video-audio-toggle";
import { ComposeOverlay } from "./compose/compose-overlay";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
import { useNetworks } from "@/hooks/use-networks";
import { useAuthStore } from "@/stores/auth-store";
/**
* Route-level component for /:networkId (index).
@@ -21,11 +18,6 @@ export default function NetworkRoot() {
const navigate = useNavigate();
const path = particlePath(networkId!, []);
const { data: networks } = useNetworks();
const network = networks?.find((n) => n.id === networkId);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.id === network?.admin_human.id;
const [composeActive, setComposeActive] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const statusTab: "open" | "closed" =
@@ -58,7 +50,7 @@ export default function NetworkRoot() {
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Top bar — stays in place */}
<div className="flex shrink-0 items-center justify-between p-1 border-b">
<div className="flex shrink-0 items-center p-1 border-b">
<Tabs
value={statusTab}
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
@@ -68,17 +60,6 @@ export default function NetworkRoot() {
<TabsTrigger value="closed"><CircleCheckBig className="size-3" /> Closed</TabsTrigger>
</TabsList>
</Tabs>
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => navigate(`/${networkId}/settings/billing`)}
>
<CreditCard className="size-3.5" />
Billing
</Button>
)}
</div>
{/* Scrollable content */}
+8 -1
View File
@@ -17,6 +17,7 @@ import {
useRevokeInvitation,
} from "@/hooks/use-invitations";
import { useAuthStore } from "@/stores/auth-store";
import { BillingSection } from "@/features/network-billing";
import type { Human } from "@/api/types";
function MemberRow({
@@ -175,7 +176,7 @@ export default function NetworkSettingsPage() {
<div className="flex-1" />
</div>
<ScrollArea className="flex-1">
<ScrollArea className="min-h-0 flex-1">
<SettingsGroup title="Members">
{network?.humans.map((human, index) => (
<div key={human.id}>
@@ -219,6 +220,12 @@ export default function NetworkSettingsPage() {
</p>
)}
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Billing">
<BillingSection networkId={networkId!} />
</SettingsGroup>
</>
)}
</ScrollArea>
+2
View File
@@ -9,7 +9,9 @@ export function useNetworkBilling(networkId: string | undefined) {
enabled: !!networkId,
// Refetch on window focus so the UI catches up after the user returns
// from Stripe Checkout (webhook may land a second or two later).
// FIX: doesn't work with electron
refetchOnWindowFocus: true,
refetchInterval: 10000
});
}