From 3f5d55769c10172dac6e1e0726153c50282bc3ad Mon Sep 17 00:00:00 2001 From: talksik Date: Tue, 14 Apr 2026 13:01:09 -0700 Subject: [PATCH] cleanup billing management --- js/src/App.tsx | 2 - js/src/components/ui/radio-group.tsx | 42 +++ js/src/features/network-billing.tsx | 415 +++++++++++---------------- js/src/features/network-root.tsx | 23 +- js/src/features/network-settings.tsx | 9 +- js/src/hooks/use-billing.ts | 2 + 6 files changed, 224 insertions(+), 269 deletions(-) create mode 100644 js/src/components/ui/radio-group.tsx diff --git a/js/src/App.tsx b/js/src/App.tsx index c0ce124..09999e1 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -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() { } /> } /> - } /> } /> diff --git a/js/src/components/ui/radio-group.tsx b/js/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..6bb6b5d --- /dev/null +++ b/js/src/components/ui/radio-group.tsx @@ -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) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/js/src/features/network-billing.tsx b/js/src/features/network-billing.tsx index 2eed7ec..60eed78 100644 --- a/js/src/features/network-billing.tsx +++ b/js/src/features/network-billing.tsx @@ -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 Past due; - } - if (status === "canceled") { - return Canceled; - } - if (status === "trialing") { - return Trialing; - } + if (status === "canceled") return Canceled; + if (status === "trialing") return Trialing; 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 ( - - - - {label} - {saveBadge && ( - - {saveBadge} - - )} - - {billedNote} - - -
- {perSeat} - / seat / month -
- - {total} / month for {seats} {seats === 1 ? "seat" : "seats"} - -
- - - -
+
+ {label} +
+
{value}
+
); } -function FreePlanView({ +function CadenceOption({ + value, + label, + perSeatCents, + billedNote, + saveBadge, + selected, +}: { + value: BillingCadence; + label: string; + perSeatCents: number; + billedNote: string; + saveBadge?: string; + selected: boolean; +}) { + return ( + + ); +} + +function FreeBilling({ networkId, billing, }: { @@ -114,75 +99,68 @@ function FreePlanView({ billing: BillingStatus; }) { const createCheckout = useCreateCheckoutSession(networkId); + const [cadence, setCadence] = useState("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 ( -
- - -
-
- Llink Free - - Current plan · up to 50 messages per day - -
+ <> + + Llink Free Free
-
-
- -
-

- Upgrade to Pro -

- -
- handleUpgrade("monthly")} - isLoading={createCheckout.isPending} - /> - 0 ? `Save ${savingsPct}%` : undefined} - billedNote="Billed annually" - onUpgrade={() => handleUpgrade("annual")} - isLoading={createCheckout.isPending} - /> -
+ } + /> + + setCadence(v as BillingCadence)} + className="gap-0" + > + 0 ? `Save ${savingsPct}%` : undefined} + selected={cadence === "annual"} + /> + + + +
+
-
+ ); } -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 ( -
+ <> {billing.cancel_at_period_end && renewal && ( -
- Your subscription is set to downgrade to Free on {renewal}. You can - reactivate from the billing portal before then. +
+ Your subscription is set to downgrade to Free on {renewal}.
)} - {billing.plan_status === "past_due" && ( -
- Your last payment failed. Update your payment method in the billing - portal to keep Pro features active. +
+ Your last payment failed. Update your payment method to keep Pro + active.
)} - - -
-
- - Llink Pro - - - - {cadenceLabel} · {formatCents(perSeatCents)} per seat / month - -
+ + Llink Pro + Pro
-
- -
-
Seats
-
{billing.seats}
-
- {billing.cancel_at_period_end ? "Ends" : "Renews"} -
-
{renewal ?? "—"}
-
-
- - - -
- - - Seats are synced automatically when you add or remove members. Changes - are prorated. - -
- ); -} - -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 ( -
-
- + } + /> + + + + + {renewal && ( + <> + + + + )} +
- {networkName} · Billing -
- - - {!isAdmin ? ( -
- - Only the network admin can manage billing. - -
- ) : isLoading || !billing ? ( -
- Loading billing... -
- ) : error ? ( -
- - Failed to load billing. Try again later. - -
- ) : billing.plan === "pro" ? ( - - ) : ( - - )} - - -
- - Payments are processed securely by Stripe. You can view invoices - and update payment methods from the subscription portal. - -
-
-
+ ); } + +export function BillingSection({ networkId }: { networkId: string }) { + const { data: billing, isLoading, error } = useNetworkBilling(networkId); + + if (isLoading || !billing) { + return ( +
+ Loading billing... +
+ ); + } + if (error) { + return ( +
+ Failed to load billing. +
+ ); + } + if (billing.plan === "pro") { + return ; + } + return ; +} diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 82069c9..f008dd5 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -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 (
{/* Top bar — stays in place */} -
+
setStatusTab(v === "closed" ? "closed" : "open")} @@ -68,17 +60,6 @@ export default function NetworkRoot() { Closed - {isAdmin && ( - - )}
{/* Scrollable content */} diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index b2ca79d..7418f73 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -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() {
- + {network?.humans.map((human, index) => (
@@ -219,6 +220,12 @@ export default function NetworkSettingsPage() {

)} + + + + + + )} diff --git a/js/src/hooks/use-billing.ts b/js/src/hooks/use-billing.ts index bdaf09f..0ef195a 100644 --- a/js/src/hooks/use-billing.ts +++ b/js/src/hooks/use-billing.ts @@ -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 }); }