"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import type { SupabaseClient } from "@supabase/supabase-js";
import { Database, Inbox, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AppCtx, AppPage, SettingsTab } from "./app-context";
import { AppShell } from "./app-shell";
import { AuthView, type AuthMode } from "./auth-views";
import { DEFAULT_BRAND, DEFAULT_PLANS, DEFAULT_PLAN_COPY, type Brand } from "./data";
import { createDemoBackend, type DemoBackendOptions } from "./demo-backend";
import { DemoCheckout, DemoInbox, DemoPortal, FullSpinner } from "./demo-overlays";
import { InviteView } from "./invite-view";
import { Marketing } from "./marketing";
import { Onboarding } from "./onboarding";
import { entitledPlan, toSaasError, type SaasBackend } from "./saas-backend";
import { Toasts, useQuery, useToasts } from "./saas-ui";
import { createSupabaseBackend, type SupabaseBackendOptions } from "./supabase-backend";
import type { BillingInterval, InvitePreview, MyInvitation, Plan, PlanCopy, User, Workspace } from "./types";
export type { SaasBackend, DemoControls, SaasErrorCode } from "./saas-backend";
export { SaasError, toSaasError } from "./saas-backend";
export { createDemoBackend } from "./demo-backend";
export { createSupabaseBackend } from "./supabase-backend";
export type * from "./types";
export type StartView = "landing" | "pricing" | "signin" | "signup" | "app";
export interface SaasStarterKitProps {
/** A Supabase client → live mode (run backend/migrations first). */
supabase?: SupabaseClient;
/** Options for the Supabase backend (function names, redirect URLs). */
supabaseOptions?: SupabaseBackendOptions;
/** Or any implementation of SaasBackend. Takes precedence over `supabase`. */
backend?: SaasBackend;
/** Options for the in-memory demo backend when neither prop is given. */
demo?: DemoBackendOptions;
brand?: Partial<Brand>;
/** Marketing copy per plan id (features, CTA, highlight). Prices/limits come from the backend. */
planCopy?: Record<string, PlanCopy>;
/** First screen. Default "landing" (or "app" when a session exists). */
initialView?: StartView;
/** Builds the link emailed to invitees. Default: current page + `?invite=<token>`. */
inviteUrl?: (token: string) => string;
/** Called when the in-app theme toggle is used. Default behaviour toggles the `dark` class on <html>. */
onThemeChange?: (theme: "light" | "dark") => void;
/** Show the dismissible "Demo mode" badge (default true). */
showDemoBadge?: boolean;
className?: string;
}
type Route =
| { name: "marketing"; page: "home" | "pricing" }
| { name: "auth"; mode: AuthMode }
| { name: "onboarding" }
| { name: "app"; page: AppPage; tab: SettingsTab }
| { name: "invite"; token: string };
const WS_KEY = "saas-starter-kit:workspace";
const noop = () => () => {};
const readParam = (k: string) => () => (typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get(k));
function defaultInviteUrl(token: string) {
if (typeof window === "undefined") return `?invite=${token}`;
const u = new URL(window.location.href);
u.search = "";
u.hash = "";
u.searchParams.set("invite", token);
return u.toString();
}
function useDocumentTheme(onChange?: (t: "light" | "dark") => void) {
const subscribe = React.useCallback((cb: () => void) => {
const mo = new MutationObserver(cb);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => mo.disconnect();
}, []);
const theme = React.useSyncExternalStore(
subscribe,
() => (document.documentElement.classList.contains("dark") ? "dark" : "light"),
() => "light" as const,
);
const toggle = React.useCallback(() => {
const next = document.documentElement.classList.contains("dark") ? "light" : "dark";
if (onChange) onChange(next);
else {
document.documentElement.classList.toggle("dark", next === "dark");
document.documentElement.style.colorScheme = next;
}
}, [onChange]);
return [theme, toggle] as const;
}
export function SaasStarterKit({ supabase, supabaseOptions, backend: backendProp, demo, brand: brandProp, planCopy = DEFAULT_PLAN_COPY, initialView = "landing", inviteUrl = defaultInviteUrl, onThemeChange, showDemoBadge = true, className }: SaasStarterKitProps) {
const [backend] = React.useState<SaasBackend>(() => backendProp ?? (supabase ? createSupabaseBackend(supabase, supabaseOptions) : createDemoBackend(demo)));
const brand = React.useMemo(() => ({ ...DEFAULT_BRAND, ...brandProp }), [brandProp]);
const [theme, toggleTheme] = useDocumentTheme(onThemeChange);
const { toasts, push, dismiss } = useToasts();
const urlInvite = React.useSyncExternalStore(noop, readParam("invite"), () => null);
const urlCheckout = React.useSyncExternalStore(noop, readParam("checkout"), () => null);
const initialRoute = (): Route =>
initialView === "pricing"
? { name: "marketing", page: "pricing" }
: initialView === "signin" || initialView === "signup"
? { name: "auth", mode: initialView }
: { name: "marketing", page: "home" };
const [route, setRoute] = React.useState<Route>(initialRoute);
const routeRef = React.useRef(route);
React.useLayoutEffect(() => {
routeRef.current = route;
});
const [user, setUser] = React.useState<User | null>(null);
const [ready, setReady] = React.useState(false);
const [email, setEmail] = React.useState("");
const [chosenPlan, setChosenPlan] = React.useState<{ id: string; interval: BillingInterval } | null>(null);
const [plans, setPlans] = React.useState<Plan[]>(DEFAULT_PLANS);
const [workspaces, setWorkspaces] = React.useState<Workspace[]>([]);
const [wsId, setWsId] = React.useState<string | null>(null);
const [onboardingWs, setOnboardingWs] = React.useState<Workspace | null>(null);
const [pendingInvite, setPendingInvite] = React.useState<string | null>(null);
const [invitePreview, setInvitePreview] = React.useState<InvitePreview | null>(null);
const [checkout, setCheckout] = React.useState<{ sessionId: string; fromOnboarding: boolean } | null>(null);
const [portal, setPortal] = React.useState(false);
const [inbox, setInbox] = React.useState(false);
const [unread, setUnread] = React.useState(0);
const [badge, setBadge] = React.useState(true);
const [billingNonce, setBillingNonce] = React.useState(0);
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
const t = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(t);
}, []);
React.useEffect(() => {
let live = true;
backend.listPlans().then((p) => live && p.length && setPlans(p), () => undefined);
return () => {
live = false;
};
}, [backend]);
React.useEffect(() => {
const d = backend.demo;
if (!d) return;
return d.onInbox(() => setUnread(d.inbox().filter((e) => !e.opened).length));
}, [backend]);
/* --------------------------------------------------------- workspace state */
const rememberWs = (id: string | null) => {
setWsId(id);
try {
if (id) window.localStorage.setItem(WS_KEY, id);
} catch {
/* storage blocked */
}
};
const loadWorkspaces = React.useCallback(async () => {
const list = await backend.listWorkspaces();
setWorkspaces(list);
return list;
}, [backend]);
/** Where a freshly signed-in user should land. */
const afterAuth = React.useCallback(
async (opts?: { invite?: string | null }) => {
const token = opts?.invite ?? pendingInvite;
try {
const list = await loadWorkspaces();
if (token) {
setRoute({ name: "invite", token });
return;
}
if (!list.length) {
setRoute({ name: "onboarding" });
return;
}
let saved: string | null = null;
try {
saved = window.localStorage.getItem(WS_KEY);
} catch {
saved = null;
}
setWsId((cur) => (cur && list.some((w) => w.id === cur) ? cur : saved && list.some((w) => w.id === saved) ? saved : list[0].id));
setRoute({ name: "app", page: "projects", tab: "profile" });
} catch (e) {
push(toSaasError(e).message, { tone: "error" });
}
},
[loadWorkspaces, pendingInvite, push],
);
const afterAuthRef = React.useRef(afterAuth);
React.useLayoutEffect(() => {
afterAuthRef.current = afterAuth;
});
/* ----------------------------------------------------------- bootstrapping */
React.useEffect(() => {
let live = true;
backend.getSession().then(async (u) => {
if (!live) return;
setUser(u);
if (urlInvite) {
setPendingInvite(urlInvite);
setRoute({ name: "invite", token: urlInvite });
if (u) await loadWorkspaces().catch(() => undefined);
} else if (u && (initialView === "app" || initialView === "landing" || urlCheckout)) {
await afterAuthRef.current();
if (urlCheckout) {
setRoute({ name: "app", page: "billing", tab: "profile" });
push(urlCheckout === "success" ? "Payment received — your plan updates in a few seconds." : "Checkout canceled — nothing was charged.", { tone: urlCheckout === "success" ? "ok" : "error" });
if (urlCheckout === "success") [1500, 4000, 8000].forEach((ms) => window.setTimeout(() => setBillingNonce((n) => n + 1), ms));
try {
const url = new URL(window.location.href);
url.searchParams.delete("checkout");
window.history.replaceState(null, "", url.toString());
} catch {
/* ignore */
}
}
}
setReady(true);
});
const off = backend.onAuthChange((u, event) => {
setUser(u);
if (event === "PASSWORD_RECOVERY") setRoute({ name: "auth", mode: "reset" });
else if (event === "SIGNED_OUT") {
setWorkspaces([]);
setWsId(null);
setOnboardingWs(null);
if (routeRef.current.name !== "invite" && routeRef.current.name !== "auth") setRoute({ name: "marketing", page: "home" });
} else if (event === "SIGNED_IN") {
const r = routeRef.current;
if (r.name === "auth" && r.mode !== "reset") void afterAuthRef.current();
}
});
return () => {
live = false;
off();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot once per backend
}, [backend]);
/* ------------------------------------------------------------ app data */
const workspace = workspaces.find((w) => w.id === wsId) ?? workspaces[0] ?? null;
const inApp = route.name === "app" && !!user && !!workspace;
const billingKey = inApp && workspace ? `billing|${workspace.id}|${billingNonce}` : null;
const billing = useQuery(billingKey, async () => {
const [subscription, usage] = await Promise.all([backend.getSubscription(workspace!.id), backend.getUsage(workspace!.id)]);
return { subscription, usage };
});
const invitesQ = useQuery(user && (route.name === "app" || route.name === "onboarding") ? `my-invites|${user.id}|${workspaces.length}` : null, () => backend.myInvitations());
const refreshBilling = React.useCallback(() => setBillingNonce((n) => n + 1), []);
/* ------------------------------------------------------------ billing flows */
const startCheckout = async (ws: Workspace, planId: string, interval: BillingInterval, fromOnboarding = false) => {
const r = await backend.startCheckout(ws.id, planId, interval);
if (r.kind === "updated") {
push(`Plan changed to ${plans.find((p) => p.id === planId)?.name ?? planId}`);
refreshBilling();
return;
}
if (r.url.startsWith("demo-checkout:")) {
rememberWs(ws.id);
setCheckout({ sessionId: r.url.slice("demo-checkout:".length), fromOnboarding });
return;
}
window.location.assign(r.url);
};
const openPortal = async () => {
if (!workspace) return;
const r = await backend.openBillingPortal(workspace.id);
if (r.url.startsWith("demo-portal:")) setPortal(true);
else window.location.assign(r.url);
};
const acceptInvite = async (inv: MyInvitation) => {
const id = await backend.acceptInvitation({ id: inv.id });
await loadWorkspaces();
rememberWs(id);
setOnboardingWs(null);
setRoute({ name: "app", page: "projects", tab: "profile" });
push(`Welcome to ${inv.workspaceName}!`);
invitesQ.reload();
};
const signOut = async () => {
await backend.signOut();
setRoute({ name: "marketing", page: "home" });
push("Signed out");
};
/* ------------------------------------------------------------------ ctx */
const ctx: AppCtx | null =
user && workspace
? {
backend,
brand,
user,
workspace,
workspaces,
plans,
planCopy,
subscription: billing.data?.subscription ?? null,
usage: billing.data?.usage ?? null,
now,
toast: push,
navigate: (page, tab) => setRoute({ name: "app", page, tab: tab ?? (route.name === "app" ? route.tab : "profile") }),
refreshBilling,
refreshWorkspaces: () => void loadWorkspaces(),
startCheckout: (planId, interval) => startCheckout(workspace, planId, interval),
openPortal,
inviteUrl,
openInviteLink: backend.demo
? (token) => {
setPendingInvite(token);
setRoute({ name: "invite", token });
}
: undefined,
onUserChange: setUser,
onWorkspaceDeleted: () => {
push("Workspace deleted");
void loadWorkspaces().then((list) => {
rememberWs(list[0]?.id ?? null);
setRoute(list.length ? { name: "app", page: "projects", tab: "profile" } : { name: "onboarding" });
});
},
onLeftWorkspace: () => {
push(`You left ${workspace.name}`);
void loadWorkspaces().then((list) => {
rememberWs(list[0]?.id ?? null);
setRoute(list.length ? { name: "app", page: "projects", tab: "profile" } : { name: "onboarding" });
});
},
}
: null;
const overlay = !!checkout || portal || inbox;
const demoMode = backend.mode === "demo";
const openInbox = demoMode ? () => setInbox(true) : undefined;
const goAuth = (mode: AuthMode) => setRoute({ name: "auth", mode });
/* ---------------------------------------------------------------- render */
let screen: React.ReactNode;
if (!ready) screen = <FullSpinner />;
else if (route.name === "marketing")
screen = (
<Marketing
page={route.page}
brand={brand}
plans={plans}
planCopy={planCopy}
user={user}
onNavigate={(page) => setRoute({ name: "marketing", page })}
onSignIn={() => goAuth("signin")}
onSignUp={(plan) => {
setChosenPlan(plan ?? null);
goAuth("signup");
}}
onOpenApp={() => void afterAuth()}
/>
);
else if (route.name === "auth")
screen = (
<AuthView
mode={route.mode}
onMode={goAuth}
backend={backend}
brand={brand}
email={email}
onEmail={setEmail}
onAuthed={setUser}
onPasswordUpdated={() => {
push("Password updated — you're signed in");
void afterAuth();
}}
onBack={() => setRoute({ name: "marketing", page: "home" })}
onOpenInbox={openInbox}
invite={pendingInvite ? invitePreview : null}
chosenPlan={chosenPlan?.id}
/>
);
else if (route.name === "invite")
screen = (
<InviteView
backend={backend}
brand={brand}
token={route.token}
user={user}
onPreview={setInvitePreview}
onSignUp={(e) => {
setPendingInvite(route.token);
setEmail(e);
goAuth("signup");
}}
onSignIn={(e) => {
setPendingInvite(route.token);
setEmail(e);
goAuth("signin");
}}
onSwitchAccount={async (e) => {
setPendingInvite(route.token);
await backend.signOut();
setEmail(e);
goAuth("signup");
}}
onJoined={(id, name) => {
setPendingInvite(null);
setInvitePreview(null);
void loadWorkspaces().then(() => {
rememberWs(id);
setRoute({ name: "app", page: "projects", tab: "profile" });
push(`Welcome to ${name}!`);
});
}}
onBack={() => {
setPendingInvite(null);
if (user) void afterAuth({ invite: null }).then(() => setPendingInvite(null));
else setRoute({ name: "marketing", page: "home" });
}}
/>
);
else if (route.name === "onboarding" && user)
screen = (
<Onboarding
backend={backend}
user={user}
brand={brand}
plans={plans}
planCopy={planCopy}
chosenPlan={chosenPlan}
workspace={onboardingWs}
onWorkspace={(w) => {
setOnboardingWs(w);
setWorkspaces((list) => [...list.filter((x) => x.id !== w.id), w]);
rememberWs(w.id);
}}
inviteUrl={inviteUrl}
onChoosePlan={(w, planId, interval) => startCheckout(w, planId, interval, true)}
onFinish={(w) => {
rememberWs(w.id);
setOnboardingWs(null);
setChosenPlan(null);
void loadWorkspaces();
setRoute({ name: "app", page: "projects", tab: "profile" });
push(`${w.name} is ready`);
}}
onJoin={acceptInvite}
onSignOut={() => void signOut()}
/>
);
else if (route.name === "app" && ctx)
screen = (
<AppShell
ctx={ctx}
page={route.page}
tab={route.tab}
onPage={(page, tab) => setRoute({ name: "app", page, tab: tab ?? route.tab })}
myInvites={invitesQ.data ?? []}
onAcceptInvite={acceptInvite}
onDeclineInvite={async (inv) => {
await backend.declineInvitation(inv.id);
invitesQ.reload();
push("Invitation declined");
}}
onSwitchWorkspace={(id) => {
rememberWs(id);
setRoute({ name: "app", page: "projects", tab: "profile" });
push(`Switched to ${workspaces.find((w) => w.id === id)?.name}`);
}}
onCreateWorkspace={() => {
setOnboardingWs(null);
setChosenPlan(null);
setRoute({ name: "onboarding" });
}}
onSignOut={() => void signOut()}
onMarketing={() => setRoute({ name: "marketing", page: "home" })}
onOpenInbox={openInbox}
inboxUnread={unread}
theme={theme}
onToggleTheme={toggleTheme}
blocked={overlay}
/>
);
else screen = <FullSpinner />;
const screenKey = route.name === "marketing" ? "marketing" : route.name === "auth" ? "auth" : route.name === "app" ? "app" : route.name;
const checkoutWs = workspace;
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", className)}>
<AnimatePresence initial={false}>
{demoMode && showDemoBadge && badge && (
<motion.div key="demo" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="relative z-10 shrink-0 overflow-hidden border-b bg-gradient-to-r from-violet-500/[0.08] via-fuchsia-500/[0.06] to-transparent">
<div role="status" className="flex items-center gap-2 px-3 py-1.5 text-[11px] sm:px-4">
<Database className="size-3.5 shrink-0 text-primary" aria-hidden />
<span className="min-w-0 flex-1 truncate">
<span className="font-semibold">Demo mode</span>
<span className="text-muted-foreground"> — connect Supabase to go live. Fake sign-in & Stripe test mode; data resets on reload.</span>
</span>
<button type="button" onClick={() => setInbox(true)} className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 font-medium text-primary outline-none hover:bg-primary/10 focus-visible:ring-2 focus-visible:ring-ring">
<Inbox className="size-3" aria-hidden /> Inbox{unread > 0 && <span className="rounded-full bg-primary px-1 text-[9px] font-bold text-primary-foreground">{unread}</span>}
</button>
<button type="button" aria-label="Dismiss demo notice" onClick={() => setBadge(false)} className="grid size-5 shrink-0 place-items-center rounded text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-3" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div inert={overlay || undefined} className="relative min-h-0 flex-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={screenKey} className="h-full" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{screen}
</motion.div>
</AnimatePresence>
</div>
<AnimatePresence>
{checkout && (
<DemoCheckout
key="checkout"
backend={backend}
sessionId={checkout.sessionId}
brandName={brand.name}
onCancel={() => {
setCheckout(null);
push("Checkout canceled — nothing was charged.", { tone: "error" });
}}
onPaid={() => {
const from = checkout.fromOnboarding;
setCheckout(null);
setOnboardingWs(null);
setChosenPlan(null);
void loadWorkspaces();
refreshBilling();
setRoute({ name: "app", page: from ? "projects" : "billing", tab: "profile" });
push("Subscription started — welcome aboard!");
}}
/>
)}
{portal && checkoutWs && (
<DemoPortal
key="portal"
backend={backend}
workspaceId={checkoutWs.id}
workspaceName={checkoutWs.name}
subscription={billing.data?.subscription ?? null}
planName={plans.find((p) => p.id === entitledPlan(billing.data?.subscription ?? null))?.name ?? "Free"}
onChanged={refreshBilling}
onClose={() => setPortal(false)}
/>
)}
{inbox && (
<DemoInbox
key="inbox"
backend={backend}
brandName={brand.name}
onClose={() => setInbox(false)}
onOpened={(r) => {
setInbox(false);
if (r.kind === "invite" && r.token) {
setPendingInvite(r.token);
setRoute({ name: "invite", token: r.token });
} else if (r.kind === "signed_in" && routeRef.current.name !== "auth") void afterAuth();
}}
/>
)}
</AnimatePresence>
<Toasts toasts={toasts} dismiss={dismiss} />
</div>
</MotionConfig>
);
}
export default SaasStarterKit;