implement core foundation
This commit is contained in:
@@ -14,6 +14,7 @@ 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";
|
||||
|
||||
@@ -71,6 +72,7 @@ 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>
|
||||
|
||||
@@ -2,18 +2,22 @@ import { appConfig } from "@/config/env";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
BillingStatusSchema,
|
||||
CheckoutSessionResponseSchema,
|
||||
DepotObjectSchema,
|
||||
GetLivekitTokenResponseSchema,
|
||||
HumanSchema,
|
||||
ListInvitationsResponseSchema,
|
||||
ListNetworksResponseSchema,
|
||||
NetworkSchema,
|
||||
PortalSessionResponseSchema,
|
||||
PrepareUploadResponseSchema,
|
||||
SignInResponseSchema,
|
||||
} from "./types";
|
||||
import type {
|
||||
AcceptInvitationRequest,
|
||||
AddMembersRequest,
|
||||
BillingCadence,
|
||||
CreateNetworkRequest,
|
||||
PrepareUploadRequest,
|
||||
RequestCodeRequest,
|
||||
@@ -216,6 +220,33 @@ class ApiClient {
|
||||
async getLivekitToken(networkId: string, streamId: string) {
|
||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
||||
}
|
||||
|
||||
// --- Billing (network admin only) ---
|
||||
|
||||
async getNetworkBilling(networkId: string) {
|
||||
return this.request(
|
||||
BillingStatusSchema,
|
||||
"GET",
|
||||
`/networks/${networkId}/billing`,
|
||||
);
|
||||
}
|
||||
|
||||
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||
return this.request(
|
||||
CheckoutSessionResponseSchema,
|
||||
"POST",
|
||||
`/networks/${networkId}/billing/checkout-session`,
|
||||
{ cadence },
|
||||
);
|
||||
}
|
||||
|
||||
async createPortalSession(networkId: string) {
|
||||
return this.request(
|
||||
PortalSessionResponseSchema,
|
||||
"POST",
|
||||
`/networks/${networkId}/billing/portal-session`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
|
||||
@@ -262,3 +262,45 @@ export const SignInResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||
|
||||
// --- Billing types ---
|
||||
|
||||
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||
|
||||
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||
|
||||
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||
export const BillingPlanStatusSchema = z.enum([
|
||||
"active",
|
||||
"trialing",
|
||||
"past_due",
|
||||
"canceled",
|
||||
"incomplete",
|
||||
"incomplete_expired",
|
||||
"unpaid",
|
||||
]);
|
||||
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||
|
||||
export const BillingStatusSchema = z.object({
|
||||
plan: NetworkPlanSchema,
|
||||
plan_status: BillingPlanStatusSchema,
|
||||
cadence: BillingCadenceSchema.nullable(),
|
||||
seats: z.number().int(),
|
||||
current_period_end: z.coerce.date().nullable(),
|
||||
cancel_at_period_end: z.boolean(),
|
||||
price_monthly_cents: z.number().int(),
|
||||
price_annual_cents: z.number().int(),
|
||||
});
|
||||
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||
|
||||
export const CheckoutSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||
|
||||
export const PortalSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Check, 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 { Separator } from "@/components/ui/separator";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import { WindowControls } from "@/components/window-controls";
|
||||
import { useNetworks } from "@/hooks/use-networks";
|
||||
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 {
|
||||
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 PricingCard({
|
||||
cadence,
|
||||
pricePerSeatCents,
|
||||
seats,
|
||||
saveBadge,
|
||||
billedNote,
|
||||
onUpgrade,
|
||||
isLoading,
|
||||
}: {
|
||||
cadence: BillingCadence;
|
||||
pricePerSeatCents: number;
|
||||
seats: number;
|
||||
saveBadge?: string;
|
||||
billedNote: string;
|
||||
onUpgrade: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function FreePlanView({
|
||||
networkId,
|
||||
billing,
|
||||
}: {
|
||||
networkId: string;
|
||||
billing: BillingStatus;
|
||||
}) {
|
||||
const createCheckout = useCreateCheckoutSession(networkId);
|
||||
|
||||
const handleUpgrade = (cadence: BillingCadence) => {
|
||||
createCheckout.mutate(cadence, {
|
||||
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 particles per day
|
||||
</CardDescription>
|
||||
</div>
|
||||
<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>
|
||||
<ul className="text-muted-foreground mb-4 space-y-1.5 px-1 text-sm">
|
||||
{features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2">
|
||||
<Check className="text-primary size-3.5" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProPlanView({
|
||||
networkId,
|
||||
billing,
|
||||
}: {
|
||||
networkId: string;
|
||||
billing: BillingStatus;
|
||||
}) {
|
||||
const createPortal = useCreatePortalSession(networkId);
|
||||
|
||||
const handleManage = () => {
|
||||
createPortal.mutate(undefined, {
|
||||
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 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 (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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 />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="no-drag text-muted-foreground"
|
||||
onClick={() => navigate(`/${networkId}/settings`)}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/api/client";
|
||||
import type { BillingCadence } from "@/api/types";
|
||||
|
||||
export function useNetworkBilling(networkId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["network-billing", networkId],
|
||||
queryFn: () => apiClient.getNetworkBilling(networkId!),
|
||||
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).
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCheckoutSession(networkId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (cadence: BillingCadence) =>
|
||||
apiClient.createCheckoutSession(networkId, cadence),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePortalSession(networkId: string) {
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.createPortalSession(networkId),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user