Fazekit

Code

"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 &amp; 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;

More in Business

View all →