implement core foundation

This commit is contained in:
talksik
2026-04-14 11:00:46 -07:00
parent aff18d82db
commit 279a3e52c2
25 changed files with 1455 additions and 62 deletions
+2
View File
@@ -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>
+31
View File
@@ -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({
+42
View File
@@ -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>;
+350
View File
@@ -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>
);
}
+27
View File
@@ -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),
});
}