Building apps

Recipes

A recipe is a small, finished piece of an app that the builder copies in rather than writes fresh: a form, a modal, a search box, the payments module. This is the list it works from, with the code as it ships. If you are writing for a Ripping app by hand, these are the pieces that already exist.

  • empty-state

    A list's empty state: icon, one sentence, one action.

    src/components/EmptyState.tsx

    import type { LucideIcon } from "lucide-react";
    
    export function EmptyState({ icon: Icon, title, body, action, onAction }: { icon: LucideIcon; title: string; body: string; action: string; onAction: () => void }) {
      return (
        <div className="flex flex-col items-center justify-center rounded-card border border-dashed border-mute/40 bg-surface px-6 py-12 text-center">
          <div className="grid size-12 place-items-center rounded-full bg-bg"><Icon className="size-5 text-mute" /></div>
          <p className="mt-4 text-base font-semibold text-ink">{title}</p>
          <p className="mt-1 max-w-xs text-sm text-mute">{body}</p>
          <button type="button" onClick={onAction} className="mt-5 rounded-card bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90">{action}</button>
        </div>
      );
    }
  • form

    A form with inline validation, disabled-while-saving, and a success state. Adapt the fields.

    src/components/ItemForm.tsx

    import { useState } from "react";
    
    export type ItemInput = { name: string; notes: string };
    export function ItemForm({ initial, onSubmit, onCancel }: { initial?: ItemInput; onSubmit: (v: ItemInput) => void | Promise<void>; onCancel?: () => void }) {
      const [v, setV] = useState<ItemInput>(initial ?? { name: "", notes: "" });
      const [errors, setErrors] = useState<Partial<Record<keyof ItemInput, string>>>({});
      const [saving, setSaving] = useState(false);
      const validate = () => { const e: typeof errors = {}; if (v.name.trim().length < 2) e.name = "Give it a name (2+ characters)"; setErrors(e); return Object.keys(e).length === 0; };
      return (
        <form className="flex flex-col gap-3" onSubmit={async (e) => { e.preventDefault(); if (!validate()) return; setSaving(true); try { await onSubmit({ name: v.name.trim(), notes: v.notes.trim() }); } finally { setSaving(false); } }} noValidate>
          <label className="flex flex-col gap-1 text-sm"><span className="font-medium text-ink">Name</span>
            <input value={v.name} onChange={(e) => setV({ ...v, name: e.target.value })} aria-invalid={!!errors.name} className="h-11 rounded-card border border-mute/30 bg-bg px-3 text-base text-ink outline-none focus:border-accent" />
            {errors.name && <span className="text-xs text-red-600">{errors.name}</span>}</label>
          <label className="flex flex-col gap-1 text-sm"><span className="font-medium text-ink">Notes</span>
            <textarea value={v.notes} onChange={(e) => setV({ ...v, notes: e.target.value })} rows={3} className="rounded-card border border-mute/30 bg-bg px-3 py-2 text-base text-ink outline-none focus:border-accent" /></label>
          <div className="mt-1 flex justify-end gap-2">
            {onCancel && <button type="button" onClick={onCancel} className="h-11 rounded-card px-4 text-sm text-mute hover:text-ink">Cancel</button>}
            <button type="submit" disabled={saving} className="h-11 rounded-card bg-accent px-5 text-sm font-medium text-accent-fg disabled:opacity-60">{saving ? "Saving…" : "Save"}</button>
          </div>
        </form>
      );
    }
  • confirm

    Confirm a destructive action with a small dialog and a hook to use it.

    src/components/Confirm.tsx

    import { useState } from "react";
    import { Modal } from "./Modal";
    
    export function useConfirm() {
      const [state, setState] = useState<{ title: string; body: string; resolve: (ok: boolean) => void } | null>(null);
      const confirm = (title: string, body: string) => new Promise<boolean>((resolve) => setState({ title, body, resolve }));
      const dialog = state ? (
        <Modal open title={state.title} onClose={() => { state.resolve(false); setState(null); }}>
          <p className="text-sm text-mute">{state.body}</p>
          <div className="mt-4 flex justify-end gap-2">
            <button type="button" onClick={() => { state.resolve(false); setState(null); }} className="h-11 rounded-card px-4 text-sm text-mute hover:text-ink">Keep it</button>
            <button type="button" onClick={() => { state.resolve(true); setState(null); }} className="h-11 rounded-card bg-red-600 px-5 text-sm font-medium text-white hover:opacity-90">Delete</button>
          </div>
        </Modal>
      ) : null;
      return { confirm, dialog };
    }
  • toast

    Lightweight toasts: useToast() returns a function toast(text, tone?: "ok" | "err"); ToastProvider wraps the app.

    src/components/Toast.tsx

    import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
    
    type Toast = { id: number; text: string; tone: "ok" | "err" };
    const Ctx = createContext<(text: string, tone?: Toast["tone"]) => void>(() => {});
    export const useToast = () => useContext(Ctx);
    export function ToastProvider({ children }: { children: ReactNode }) {
      const [list, setList] = useState<Toast[]>([]);
      const push = useCallback((text: string, tone: Toast["tone"] = "ok") => { const id = Date.now() + Math.random(); setList((l) => [...l, { id, text, tone }]); setTimeout(() => setList((l) => l.filter((t) => t.id !== id)), 2500); }, []);
      return (
        <Ctx.Provider value={push}>
          {children}
          <div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4">
            {list.map((t) => <div key={t.id} role="status" className={"rounded-card px-4 py-2 text-sm shadow-lg " + (t.tone === "err" ? "bg-red-600 text-white" : "bg-ink text-bg")}>{t.text}</div>)}
          </div>
        </Ctx.Provider>
      );
    }
  • tabs

    Accessible tabs with keyboard arrows; content by key.

    src/components/Tabs.tsx

    import { useState, type ReactNode } from "react";
    
    export function Tabs({ items, initial }: { items: { key: string; label: string; content: ReactNode }[]; initial?: string }) {
      const [active, setActive] = useState(initial ?? items[0]?.key);
      const idx = Math.max(0, items.findIndex((t) => t.key === active));
      return (
        <div>
          <div role="tablist" className="flex gap-1 rounded-card bg-bg p-1" onKeyDown={(e) => { if (e.key === "ArrowRight") setActive(items[(idx + 1) % items.length].key); if (e.key === "ArrowLeft") setActive(items[(idx - 1 + items.length) % items.length].key); }}>
            {items.map((t) => <button key={t.key} role="tab" aria-selected={t.key === active} tabIndex={t.key === active ? 0 : -1} onClick={() => setActive(t.key)} className={"h-10 flex-1 rounded-card text-sm font-medium transition-colors " + (t.key === active ? "bg-surface text-ink shadow-sm" : "text-mute hover:text-ink")}>{t.label}</button>)}
          </div>
          <div role="tabpanel" className="mt-4">{items[idx]?.content}</div>
        </div>
      );
    }
  • line-chart

    A responsive SVG line chart with area fill, axis labels and a hover tooltip. No library.

    src/components/LineChart.tsx

    import { useState } from "react";
    
    export function LineChart({ points, height = 160, format = (v: number) => String(v) }: { points: { label: string; value: number }[]; height?: number; format?: (v: number) => string }) {
      const [hover, setHover] = useState<number | null>(null);
      const w = 600, h = height, pad = 24;
      const max = Math.max(1, ...points.map((p) => p.value)), min = Math.min(0, ...points.map((p) => p.value));
      const x = (i: number) => pad + (i / Math.max(1, points.length - 1)) * (w - pad * 2);
      const y = (v: number) => h - pad - ((v - min) / (max - min || 1)) * (h - pad * 2);
      const d = points.map((p, i) => (i ? "L" : "M") + x(i) + " " + y(p.value)).join(" ");
      return (
        <div className="relative">
          <svg viewBox={"0 0 " + w + " " + h} className="h-auto w-full" onMouseLeave={() => setHover(null)}>
            <path d={d + " L" + x(points.length - 1) + " " + (h - pad) + " L" + x(0) + " " + (h - pad) + " Z"} className="fill-accent/10" />
            <path d={d} className="fill-none stroke-accent" strokeWidth={2} strokeLinejoin="round" />
            {points.map((p, i) => <g key={i} onMouseEnter={() => setHover(i)}><rect x={x(i) - 10} y={0} width={20} height={h} fill="transparent" /><circle cx={x(i)} cy={y(p.value)} r={hover === i ? 5 : 3} className="fill-accent" /></g>)}
            <text x={pad} y={h - 6} className="fill-mute text-[10px]">{points[0]?.label}</text>
            <text x={w - pad} y={h - 6} textAnchor="end" className="fill-mute text-[10px]">{points[points.length - 1]?.label}</text>
          </svg>
          {hover != null && <div className="pointer-events-none absolute left-1/2 top-2 -translate-x-1/2 rounded-card bg-ink px-2 py-1 text-xs text-bg">{points[hover].label}: {format(points[hover].value)}</div>}
        </div>
      );
    }
  • shared

    Records other people can see (posts, reviews, bookings, a shared board), enforced by Ripping: useShared<T>(name) with add, update, remove. Also use_recipe collections and declare each collection's rule there.

    src/lib/shared.ts

    import { useCallback, useEffect, useRef, useState } from "react";
    import { API, APP_ID } from "./app";
    import type { COLLECTIONS } from "./collections";
    
    /**
     * Records other people can see: posts, reviews, listings, bookings sent to the owner, a shared board. Declare each
     * collection and its rule in lib/collections.ts; Ripping enforces the rule. Private per-person data uses useStore.
     *   const { items, add, update, remove, ready, error } = useShared<Review>("reviews");
     * Each item has your fields plus id, ownerId, ownerName, mine (you added it), createdAt and updatedAt. The list refreshes
     * every 20 seconds and after each change. Adding needs a signed-in person.
     */
    export type SharedMeta = { id: string; ownerId: string | null; ownerName: string | null; mine: boolean; createdAt: string; updatedAt: string };
    export type Shared<T> = T & SharedMeta;
    export type CollectionName = keyof typeof COLLECTIONS & string;
    type Wire = { id: string; data: Record<string, unknown>; ownerId: string | null; ownerName: string | null; mine: boolean; createdAt: string; updatedAt: string };
    
    const token = () => { try { return localStorage.getItem("auth.token"); } catch { return null; } };
    async function call<R>(path: string, init: RequestInit = {}): Promise<R> {
      const t = token();
      const r = await fetch(`${API}/api/apps/${APP_ID}/records/${path}`, { ...init, headers: { ...(t ? { authorization: `Bearer ${t}` } : {}), ...(init.body ? { "content-type": "application/json" } : {}) } });
      const j = (await r.json().catch(() => ({}))) as R & { error?: string };
      if (!r.ok) throw new Error(j.error ?? `Request failed (${r.status})`);
      return j;
    }
    function unwrap<T>(w: Wire): Shared<T> {
      return { ...(w.data as T), id: w.id, ownerId: w.ownerId, ownerName: w.ownerName, mine: w.mine, createdAt: w.createdAt, updatedAt: w.updatedAt };
    }
    
    export function useShared<T extends object>(collection: CollectionName, opts: { mine?: boolean; refreshMs?: number } = {}) {
      const [items, setItems] = useState<Shared<T>[]>([]);
      const [ready, setReady] = useState(false);
      const [error, setError] = useState<string | null>(null);
      const alive = useRef(true);
      const name = encodeURIComponent(collection);
      const path = `${name}${opts.mine ? "?mine=1" : ""}`;
      const refresh = useCallback(async () => {
        try { const j = await call<{ records: Wire[] }>(path); if (alive.current) { setItems(j.records.map((w) => unwrap<T>(w))); setError(null); } }
        catch (e) { if (alive.current) setError(e instanceof Error ? e.message : String(e)); }
        finally { if (alive.current) setReady(true); }
      }, [path]);
      useEffect(() => {
        alive.current = true;
        void refresh();
        const timer = setInterval(() => { void refresh(); }, opts.refreshMs ?? 20_000);
        const onAuth = () => { void refresh(); };
        window.addEventListener("auth:change", onAuth);
        return () => { alive.current = false; clearInterval(timer); window.removeEventListener("auth:change", onAuth); };
      }, [refresh, opts.refreshMs]);
      const add = useCallback(async (data: T) => {
        const j = await call<{ record: Wire }>(name, { method: "POST", body: JSON.stringify({ data }) });
        const rec = unwrap<T>(j.record); setItems((p) => [rec, ...p]); return rec;
      }, [name]);
      const update = useCallback(async (id: string, patch: Partial<T>) => {
        setItems((p) => p.map((x) => (x.id === id ? { ...x, ...patch } : x)));
        try {
          const j = await call<{ record: Wire }>(`${name}?id=${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify({ data: patch }) });
          const rec = unwrap<T>(j.record); setItems((p) => p.map((x) => (x.id === id ? rec : x))); return rec;
        } catch (e) { void refresh(); throw e; }
      }, [name, refresh]);
      const remove = useCallback(async (id: string) => {
        setItems((p) => p.filter((x) => x.id !== id));
        try { await call(`${name}?id=${encodeURIComponent(id)}`, { method: "DELETE" }); } catch (e) { void refresh(); throw e; }
      }, [name, refresh]);
      return { items, ready, error, add, update, remove, refresh };
    }
    
  • files

    File uploads stored on Ripping: uploadFile(file, { visibility }) and useFiles() (list, upload, remove). Public files get a permanent url to save in records.

    src/lib/files.ts

    import { useCallback, useEffect, useState } from "react";
    import { API, APP_ID } from "./app";
    
    /**
     * Files people upload (photos, documents), stored by Ripping. Needs a signed-in person.
     *   const file = await uploadFile(input.files[0], { visibility: "public" });   // file.url, file.name, file.size, file.type
     *   const { files, upload, remove, uploading, error } = useFiles();              // the signed-in person's own files
     * Public files have a permanent url you can save in a record and show to anyone (an avatar, a listing photo). Private
     * files get a link that works for an hour; list them again to refresh it. Up to 25 MB each; <FileUpload> is the button.
     */
    export type AppFile = { id: string; name: string; type: string; size: number; visibility: "public" | "private"; url: string | null; ownerId: string | null; mine: boolean; createdAt: string };
    
    const token = () => { try { return localStorage.getItem("auth.token"); } catch { return null; } };
    async function call<R>(path: string, init: RequestInit = {}): Promise<R> {
      const t = token();
      if (!t) throw new Error("Sign in to use files.");
      const r = await fetch(`${API}/api/apps/${APP_ID}/files${path}`, { ...init, headers: { authorization: `Bearer ${t}`, ...(init.body ? { "content-type": "application/json" } : {}) } });
      const j = (await r.json().catch(() => ({}))) as R & { error?: string };
      if (!r.ok) throw new Error(j.error ?? `Request failed (${r.status})`);
      return j;
    }
    
    export async function uploadFile(file: File, opts: { visibility?: "public" | "private" } = {}): Promise<AppFile> {
      const start = await call<{ id: string; uploadUrl: string }>("", { method: "POST", body: JSON.stringify({ name: file.name, type: file.type || "application/octet-stream", size: file.size, visibility: opts.visibility ?? "private" }) });
      const put = await fetch(start.uploadUrl, { method: "PUT", headers: { "content-type": file.type || "application/octet-stream" }, body: file });
      if (!put.ok) throw new Error("The upload didn't finish. Try again.");
      const done = await call<{ file: AppFile }>(`/${start.id}`, { method: "POST" });
      return done.file;
    }
    
    export function useFiles() {
      const [files, setFiles] = useState<AppFile[]>([]);
      const [uploading, setUploading] = useState(false);
      const [error, setError] = useState<string | null>(null);
      const refresh = useCallback(async () => {
        try { const j = await call<{ files: AppFile[] }>(""); setFiles(j.files); setError(null); }
        catch (e) { setError(e instanceof Error ? e.message : String(e)); }
      }, []);
      useEffect(() => {
        const t = setTimeout(() => { void refresh(); }, 0);
        const onAuth = () => { void refresh(); };
        window.addEventListener("auth:change", onAuth);
        return () => { clearTimeout(t); window.removeEventListener("auth:change", onAuth); };
      }, [refresh]);
      const upload = useCallback(async (file: File, opts?: { visibility?: "public" | "private" }) => {
        setUploading(true); setError(null);
        try { const f = await uploadFile(file, opts); setFiles((p) => [f, ...p]); return f; }
        catch (e) { setError(e instanceof Error ? e.message : String(e)); throw e; }
        finally { setUploading(false); }
      }, []);
      const remove = useCallback(async (id: string) => {
        setFiles((p) => p.filter((f) => f.id !== id));
        try { await call(`?id=${encodeURIComponent(id)}`, { method: "DELETE" }); } catch (e) { void refresh(); throw e; }
      }, [refresh]);
      return { files, uploading, error, upload, remove, refresh };
    }
    
    export const formatBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
    
  • file-upload

    An upload button with drag and drop that stores the file on Ripping and calls onUploaded(file). Needs use_recipe files.

    src/components/FileUpload.tsx

    import { useRef, useState } from "react";
    import { Loader2, Upload } from "lucide-react";
    import { Button } from "@ripping/kit";
    import { uploadFile, type AppFile } from "../lib/files";
    
    /**
     * An upload button: pick or drop a file, it uploads to Ripping and calls onUploaded with the stored file (save file.url
     * in a record to show it later). <FileUpload accept="image/*" visibility="public" onUploaded={(f) => update(id, { photo: f.url })} />
     */
    export function FileUpload({ onUploaded, accept, visibility = "private", label = "Upload a file" }: { onUploaded: (file: AppFile) => void; accept?: string; visibility?: "public" | "private"; label?: string }) {
      const input = useRef<HTMLInputElement>(null);
      const [busy, setBusy] = useState(false);
      const [error, setError] = useState<string | null>(null);
      const [over, setOver] = useState(false);
      const send = async (file: File | undefined) => {
        if (!file) return;
        setBusy(true); setError(null);
        try { onUploaded(await uploadFile(file, { visibility })); }
        catch (e) { setError(e instanceof Error ? e.message : "Upload failed"); }
        finally { setBusy(false); if (input.current) input.current.value = ""; }
      };
      return (
        <div
          onDragOver={(e) => { e.preventDefault(); setOver(true); }}
          onDragLeave={() => setOver(false)}
          onDrop={(e) => { e.preventDefault(); setOver(false); void send(e.dataTransfer.files?.[0]); }}
          className={`flex flex-col items-start gap-1.5 rounded-card border border-dashed p-3 ${over ? "border-primary bg-primary/5" : "border-border"}`}
        >
          <input ref={input} type="file" accept={accept} className="hidden" onChange={(e) => void send(e.target.files?.[0])} />
          <Button type="button" variant="secondary" onClick={() => input.current?.click()} disabled={busy}>
            {busy ? <Loader2 className="size-4 animate-spin" aria-hidden /> : <Upload className="size-4" aria-hidden />} {busy ? "Uploading…" : label}
          </Button>
          <p className="text-xs text-text-muted">{error ?? "Or drop a file here. Up to 25 MB."}</p>
        </div>
      );
    }
    
  • slack

    Posting to the owner's Slack: notify(text, { channel? }) sends a message through the proxy (signed-in users only), useChannels() lists channels for a picker. Already in every app as src/lib/slack.ts; the owner connects Slack on Connections → App Functions.

    src/lib/slack.ts

    import { useEffect, useState } from "react";
    import { API, APP_ID } from "./app";
    import { getToken } from "./auth";
    
    /**
     * Slack, through the owner's connection on Ripping (Connections → App Functions). The bot token never reaches the
     * browser; the proxy posts on the app's behalf. Both calls need a signed-in app user — a public visitor cannot post
     * to the owner's Slack. The owner's own events (inbox messages, sales, sign-ups) are posted by Ripping itself when
     * a channel is set on Settings → Notifications; nothing here is needed for those.
     *
     *   await notify("New order from Ana: 2 × Beginner course");        // to the default channel the owner set
     *   await notify("Deployed", { channel: "#ops" });                   // to a named channel, or a channel id
     *   const { channels } = useChannels();                             // for a picker
     */
    export type Channel = { id: string; name: string; private: boolean };
    
    async function slack<T>(body: Record<string, unknown>): Promise<T> {
      const t = getToken();
      if (!t) throw new Error("Sign in to do that");
      const r = await fetch(`${API}/api/apps/${APP_ID}/call`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${t}` }, body: JSON.stringify({ provider: "slack", slot: "", ...body }) });
      const j = (await r.json().catch(() => ({}))) as T & { error?: string };
      if (!r.ok) throw new Error(j.error ?? `Slack call failed (${r.status})`);
      return j;
    }
    
    /** Post a message. Slack's mrkdwn works in `text` (*bold*, <https://…|links>). Returns the message's id and channel. */
    export async function notify(text: string, opts: { channel?: string } = {}): Promise<{ ts: string; channel: string }> {
      return slack({ action: "post", text, channel: opts.channel ?? "" });
    }
    
    /** The channels the owner's bot can post to. Empty until loaded, or if Slack isn't connected. */
    export function useChannels() {
      const [channels, setChannels] = useState<Channel[]>([]);
      const [ready, setReady] = useState(false);
      const [error, setError] = useState<string | null>(null);
      useEffect(() => { let live = true; slack<{ channels: Channel[] }>({ action: "channels" }).then((j) => { if (live) { setChannels(j.channels); setError(null); } }).catch((e) => { if (live) setError(e instanceof Error ? e.message : String(e)); }).finally(() => { if (live) setReady(true); }); return () => { live = false; }; }, []);
      return { channels, ready, error };
    }
    
  • notion

    The owner's Notion databases: useDatabases() for a picker, queryDatabase(id) for the newest rows as plain values, addRow(id, values) to add one. Already in every app as src/lib/notion.ts; the owner connects Notion on Connections → App Functions.

    src/lib/notion.ts

    import { useEffect, useState } from "react";
    import { API, APP_ID } from "./app";
    import { getToken } from "./auth";
    
    /**
     * Notion, through the owner's connection on Ripping (Connections → App Functions). The token never reaches the
     * browser; the proxy reads and writes on the app's behalf, and every call needs a signed-in app user. Values are
     * plain: a row is { Name: "…", Status: "Done", Price: 12 }, keyed by the database's own property names.
     *
     *   const { databases } = useDatabases();                          // for a picker
     *   const rows = await queryDatabase(db.id);                        // newest first, flattened
     *   await addRow(db.id, { Name: "Ana", Email: "ana@…", Status: "New" });
     */
    export type Database = { id: string; title: string; url: string };
    export type Row = { id: string; url: string; created: string; values: Record<string, unknown> };
    
    async function notion<T>(body: Record<string, unknown>): Promise<T> {
      const t = getToken();
      if (!t) throw new Error("Sign in to do that");
      const r = await fetch(`${API}/api/apps/${APP_ID}/call`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${t}` }, body: JSON.stringify({ provider: "notion", slot: "", ...body }) });
      const j = (await r.json().catch(() => ({}))) as T & { error?: string };
      if (!r.ok) throw new Error(j.error ?? `Notion call failed (${r.status})`);
      return j;
    }
    
    /** The databases the owner shared with Ripping. Empty until loaded, or if Notion isn't connected. */
    export function useDatabases() {
      const [databases, setDatabases] = useState<Database[]>([]);
      const [ready, setReady] = useState(false);
      const [error, setError] = useState<string | null>(null);
      useEffect(() => { let live = true; notion<{ databases: Database[] }>({ action: "databases" }).then((j) => { if (live) { setDatabases(j.databases); setError(null); } }).catch((e) => { if (live) setError(e instanceof Error ? e.message : String(e)); }).finally(() => { if (live) setReady(true); }); return () => { live = false; }; }, []);
      return { databases, ready, error };
    }
    
    /** The newest rows of a database (up to 100), flattened to plain values. */
    export async function queryDatabase(database: string, limit = 50): Promise<Row[]> {
      return (await notion<{ rows: Row[] }>({ action: "query", database, limit })).rows;
    }
    
    /** Add a row. Keys are the database's property names; values are matched to their types (text, number, select, date, url, email, checkbox). */
    export async function addRow(database: string, values: Record<string, unknown>): Promise<{ id: string; url: string }> {
      return notion({ action: "add", database, values });
    }
    
  • hubspot

    The owner's HubSpot CRM: upsertContact(email, props) creates or updates a contact, createDeal({ name, amount, email }) makes a deal, useRecentContacts() lists the newest. Already in every app as src/lib/hubspot.ts; the owner connects HubSpot on Connections → Marketing.

    src/lib/hubspot.ts

    import { useEffect, useState } from "react";
    import { API, APP_ID } from "./app";
    import { getToken } from "./auth";
    
    /**
     * HubSpot, through the owner's connection on Ripping (Connections → Marketing). Tokens never reach the browser; the
     * proxy writes to the owner's CRM on the app's behalf, and every call needs a signed-in app user.
     *
     *   await upsertContact("ana@example.com", { firstname: "Ana", company: "Acme" });   // create, or update by email
     *   await createDeal({ name: "Order #1042", amount: 49, email: "ana@example.com" });  // tied to that contact
     *   const { contacts } = useRecentContacts();                                          // the newest 20
     */
    export type Contact = { id: string; email: string | null; firstname: string | null; lastname: string | null; company: string | null; phone: string | null; created: string };
    
    async function hubspot<T>(body: Record<string, unknown>): Promise<T> {
      const t = getToken();
      if (!t) throw new Error("Sign in to do that");
      const r = await fetch(`${API}/api/apps/${APP_ID}/call`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${t}` }, body: JSON.stringify({ provider: "hubspot", slot: "", ...body }) });
      const j = (await r.json().catch(() => ({}))) as T & { error?: string };
      if (!r.ok) throw new Error(j.error ?? `HubSpot call failed (${r.status})`);
      return j;
    }
    
    /** Create a contact, or update the one with that email. Standard properties: firstname, lastname, phone, company, website, jobtitle. */
    export async function upsertContact(email: string, props: Record<string, unknown> = {}): Promise<{ id: string; created: boolean }> {
      return hubspot({ action: "contact.upsert", email, props });
    }
    
    /** A deal; `amount` in whole currency units; `email` ties it to that contact (created if new). */
    export async function createDeal(o: { name: string; amount?: number; email?: string; stage?: string; pipeline?: string }): Promise<{ id: string }> {
      return hubspot({ action: "deal.create", ...o });
    }
    
    /** The newest contacts in the owner's CRM. */
    export function useRecentContacts(limit = 20) {
      const [contacts, setContacts] = useState<Contact[]>([]);
      const [ready, setReady] = useState(false);
      const [error, setError] = useState<string | null>(null);
      useEffect(() => { let live = true; hubspot<{ contacts: Contact[] }>({ action: "contacts.recent", limit }).then((j) => { if (live) { setContacts(j.contacts); setError(null); } }).catch((e) => { if (live) setError(e instanceof Error ? e.message : String(e)); }).finally(() => { if (live) setReady(true); }); return () => { live = false; }; }, [limit]);
      return { contacts, ready, error };
    }
    
  • payments

    Taking money: useProducts() lists the owner's products (set on Ripping's Settings → Payments), checkout(items) sends the buyer to pay with whichever processor the owner chose, confirmPayment() on the return route says whether it was paid; usePurchases() / owns(id) say what the signed-in person has bought, for unlocking paid content (a Purchases page at /purchases is already in every app). Already in every app as src/lib/pay.ts.

    src/lib/pay.ts

    import { useEffect, useState } from "react";
    import { API, APP_ID } from "./app";
    import { getToken } from "./auth";
    
    /**
     * Taking money. The owner sets the products on Ripping (Settings → Payments: name, price, one-time or monthly) and
     * picks the processor there — Ripping Payments, their own Stripe, or their own PayPal. The app names products, never
     * amounts, so nothing a buyer's browser sends can change what they pay. Buying needs no account.
     *
     *   const { products } = useProducts();
     *   await checkout([{ product: p.id, quantity: 1 }]);         // sends the buyer to pay; comes back to /thanks
     *   const r = await confirmPayment();                          // on /thanks: { paid, amountCents, currency, description }
     *
     * Who owns what (signed-in people only — a purchase belongs to the account that made it, or to the account whose
     * email the buyer paid with; a guest who pays gets an account created for that email):
     *
     *   const { owned, purchases, subscription, ready } = usePurchases();
     *   if (owned.includes(courseProductId)) …                    // unlock the thing they paid for
     *   owns(courseProductId)                                      // the same check, outside a component
     *
     * Every app with products has a Purchases page (/purchases, in the account menu) built on the same call.
     */
    export type Product = { id: string; name: string; description: string; amountCents: number; currency: string; kind: "one_time" | "subscription"; interval: "month" | "year"; active: boolean; /** "choose": the buyer names the amount (donation, tip, pay what you want) between minCents and maxCents; amountCents is the suggested one. Send it as `amount` in checkout(). */ pricing: "fixed" | "choose"; minCents: number; maxCents: number | null };
    export type Paid = { paid: boolean; status: string; amountCents: number; currency: string; description: string | null };
    export type Purchase = { id: string; productIds: string[]; description: string | null; amountCents: number; refundedCents: number; currency: string; status: string; processor: "ripping" | "stripe" | "paypal"; /** Made from the editor preview; nothing was charged. */ test: boolean; /** A chargeback: "open" while it is decided, "lost" when the buyer won it (nothing on the row is owned then). */ disputed: "open" | "lost" | null; createdAt: string };
    export type Subscription = { active: boolean; status: string; priceId: string | null; subscriptionId: string; currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean };
    export type Purchases = { purchases: Purchase[]; owned: string[]; subscription: Subscription | null };
    
    async function pay<T>(body: Record<string, unknown>): Promise<T> {
      const t = getToken();
      const r = await fetch(`${API}/api/apps/${APP_ID}/call`, { method: "POST", headers: { "content-type": "application/json", ...(t ? { authorization: `Bearer ${t}` } : {}) }, body: JSON.stringify({ provider: "pay", ...body }) });
      const j = (await r.json().catch(() => ({}))) as T & { error?: string };
      if (!r.ok) throw new Error(j.error ?? `Payment call failed (${r.status})`);
      return j;
    }
    
    export const money = (cents: number, currency = "usd") => new Intl.NumberFormat(undefined, { style: "currency", currency: currency.toUpperCase(), minimumFractionDigits: cents % 100 ? 2 : 0 }).format(cents / 100);
    export const priceLabel = (p: Product) => p.pricing === "choose" ? `from ${money(p.minCents, p.currency)}` : `${money(p.amountCents, p.currency)}${p.kind === "subscription" ? ` / ${p.interval}` : ""}`;
    
    /** The owner's products, as set on Ripping. Empty until they add some. */
    export function useProducts() {
      const [products, setProducts] = useState<Product[]>([]);
      const [ready, setReady] = useState(false);
      const [error, setError] = useState<string | null>(null);
      useEffect(() => { let live = true; pay<{ products: Product[] }>({ action: "products" }).then((j) => { if (live) { setProducts(j.products); setError(null); } }).catch((e) => { if (live) setError(e instanceof Error ? e.message : String(e)); }).finally(() => { if (live) setReady(true); }); return () => { live = false; }; }, []);
      return { products, ready, error };
    }
    
    /**
     * Send the buyer to pay. Comes back to `returnTo` (a route in this app, "/thanks" by default) with the processor's
     * reference in the address; call confirmPayment() there. `cancelTo` is where a buyer who backs out lands.
     */
    export async function checkout(items: { product: string; quantity?: number; /** For a "choose" product: the amount the buyer picked, in cents. */ amount?: number }[], opts: { returnTo?: string; cancelTo?: string; email?: string; /** A promo code the buyer typed; check it first with checkPromo(). */ code?: string } = {}): Promise<never> {
      const key = previewKey();
      if (key) return testCheckout(items, opts, key);
      const base = window.location.href.split("#")[0];
      const j = await pay<{ url: string }>({ action: "checkout", items, success_url: `${base}#${opts.returnTo ?? "/thanks"}`, cancel_url: `${base}#${opts.cancelTo ?? "/"}`, ...(opts.email ? { customer_email: opts.email } : {}), ...(opts.code ? { code: opts.code } : {}) });
      window.location.href = j.url;
      return new Promise<never>(() => {});   // the page is leaving
    }
    
    /** On the return route: whether the payment went through. Reads the reference the processor put in the address. */
    export async function confirmPayment(): Promise<Paid | null> {
      const q = new URLSearchParams(window.location.search);
      const session = q.get("session_id") ?? takeTestSession(), order = q.get("token");   // Stripe: session_id; PayPal: token; the editor: the test just made
      if (!session && !order) return null;
      const r = await pay<Paid>({ action: "confirm", ...(session ? { session_id: session } : { paypal_order: order }) });
      try { window.history.replaceState(null, "", window.location.pathname + window.location.hash); } catch { /* the address keeps its reference */ }
      return r;
    }
    
    /* ───────────────────────────── the editor's test payment ─────────────────────────────
     * Inside Ripping's editor preview a real checkout would be a real charge, so checkout() draws a payment sheet of its own
     * instead: same products, same prices, nothing charged. The purchase is recorded flagged as a test — it shows as
     * "Test" on the app's Sales tab, never counts as revenue, and only counts as owned inside the preview. Published apps
     * never see this code path: the key below only exists in the editor. */
    
    const previewKey = () => { try { return (window as unknown as { __vibePreviewKey?: string }).__vibePreviewKey ?? null; } catch { return null; } };
    let testSession: string | null = null;
    const takeTestSession = () => { const s = testSession; testSession = null; return s; };
    const go = (route: string) => { window.location.hash = route.startsWith("#") ? route.slice(1) : route; };
    
    async function testCheckout(items: { product: string; quantity?: number; amount?: number }[], opts: { returnTo?: string; cancelTo?: string; code?: string }, key: string): Promise<never> {
      const t = await pay<{ session_id: string; lines: { name: string; quantity: number; amountCents: number }[]; amountCents: number; currency: string; discountCents: number; promo: string | null }>({ action: "test-checkout", items, preview_key: key, ...(opts.code ? { code: opts.code } : {}) });
      const paid = await testSheet(t);
      if (paid) {
        await pay({ action: "test-confirm", session_id: t.session_id, preview_key: key });
        testSession = t.session_id; cached = null;
        go(opts.returnTo ?? "/thanks");
      } else go(opts.cancelTo ?? "/");
      return new Promise<never>(() => {});   // the app moves on by route, as it would coming back from a processor
    }
    
    /** The sheet: what is being bought and for how much, Pay or Cancel. Plain DOM so it works on any screen of any app. */
    function testSheet(t: { lines: { name: string; quantity: number; amountCents: number }[]; amountCents: number; currency: string; discountCents?: number; promo?: string | null }): Promise<boolean> {
      return new Promise((resolve) => {
        const root = document.createElement("div");
        root.setAttribute("role", "dialog"); root.setAttribute("aria-modal", "true"); root.setAttribute("aria-label", "Test payment");
        root.className = "fixed inset-0 z-[1000] flex items-end justify-center bg-black/50 p-4 sm:items-center";
        const rows = t.lines.map((l) => `<div class="flex justify-between gap-4 text-sm"><span>${esc(l.quantity > 1 ? `${l.quantity}× ${l.name}` : l.name)}</span><span class="tabular-nums">${esc(money(l.amountCents, t.currency))}</span></div>`).join("");
        root.innerHTML = `<div class="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 text-text shadow-xl">
          <div class="flex items-center justify-between gap-3"><p class="text-base font-semibold">Test payment</p><span class="rounded-full border border-dashed border-border px-2 py-0.5 text-[11px] font-medium">Editor only</span></div>
          <p class="mt-1 text-xs text-text-muted">Nothing is charged. This stands in for the checkout page while you're in the editor; the purchase shows as a test on your Sales tab.</p>
          <div class="mt-4 flex flex-col gap-2 border-t border-border pt-3">${rows}</div>
          ${t.discountCents ? `<div class="mt-2 flex justify-between gap-4 text-sm text-text-muted"><span>Code ${esc(t.promo ?? "")}</span><span class="tabular-nums">−${esc(money(t.discountCents, t.currency))}</span></div>` : ""}
          <div class="mt-3 flex justify-between gap-4 border-t border-border pt-3 text-sm font-semibold"><span>Total</span><span class="tabular-nums">${esc(money(t.amountCents, t.currency))}</span></div>
          <div class="mt-5 flex gap-2"><button type="button" data-act="cancel" class="h-11 flex-1 rounded-control border border-border bg-surface text-sm hover:bg-secondary">Cancel</button><button type="button" data-act="pay" class="h-11 flex-1 rounded-control bg-primary text-sm font-medium text-on-primary hover:opacity-90">Pay ${esc(money(t.amountCents, t.currency))}</button></div>
        </div>`;
        const done = (ok: boolean) => { document.removeEventListener("keydown", onKey); root.remove(); resolve(ok); };
        const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") done(false); };
        root.addEventListener("click", (e) => { const act = (e.target as HTMLElement).closest<HTMLElement>("[data-act]")?.dataset.act; if (act === "pay") done(true); else if (act === "cancel" || e.target === root) done(false); });
        document.addEventListener("keydown", onKey);
        document.body.appendChild(root);
        root.querySelector<HTMLButtonElement>("[data-act=pay]")?.focus();
      });
    }
    const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
    
    /* ───────────────────────────── what this person owns ───────────────────────────── */
    
    const EMPTY: Purchases = { purchases: [], owned: [], subscription: null };
    let cached: Purchases | null = null;
    let inflight: Promise<Purchases> | null = null;
    const listeners = new Set<(p: Purchases) => void>();
    
    /** Everything the signed-in person has bought here. Empty for a signed-out visitor. Fresh from the server each call. */
    export async function myPurchases(): Promise<Purchases> {
      if (!getToken()) { cached = EMPTY; return EMPTY; }
      const key = previewKey();
      inflight ??= pay<Purchases>({ action: "purchases", ...(key ? { preview_key: key } : {}) }).then((p) => { cached = p; listeners.forEach((l) => l(p)); return p; }).finally(() => { inflight = null; });
      return inflight;
    }
    
    /** Whether the signed-in person owns a product, from the last answer; call myPurchases() first (or use the hook). */
    export const owns = (productId: string) => (cached?.owned ?? []).includes(productId);
    
    /** Products a signed-in person owns (or an empty list while loading / signed out), plus their purchases and subscription. */
    export function usePurchases(): Purchases & { ready: boolean; error: string | null; refresh: () => Promise<void> } {
      const [state, setState] = useState<Purchases>(cached ?? EMPTY);
      const [ready, setReady] = useState(cached !== null);
      const [error, setError] = useState<string | null>(null);
      const refresh = async () => { try { setState(await myPurchases()); setError(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setReady(true); } };
      useEffect(() => {
        let live = true;
        const onChange = (p: Purchases) => { if (live) setState(p); };
        listeners.add(onChange);
        myPurchases().then((p) => { if (live) { setState(p); setError(null); } }).catch((e) => { if (live) setError(e instanceof Error ? e.message : String(e)); }).finally(() => { if (live) setReady(true); });
        return () => { live = false; listeners.delete(onChange); };
      }, []);
      return { ...state, ready, error, refresh };
    }
    
    /** Stripe's billing page for the signed-in person's subscription (change card, cancel), on whichever Stripe the owner uses. */
    export async function manageSubscription(returnTo = "/purchases"): Promise<never> {
      const t = getToken();
      const r = await fetch(`${API}/api/apps/${APP_ID}/call`, { method: "POST", headers: { "content-type": "application/json", ...(t ? { authorization: `Bearer ${t}` } : {}) }, body: JSON.stringify({ provider: "pay", action: "portal", return_url: `${window.location.href.split("#")[0]}#${returnTo}` }) });
      const j = (await r.json().catch(() => ({}))) as { url?: string; error?: string };
      if (!r.ok || !j.url) throw new Error(j.error ?? "Couldn't open the billing page");
      window.location.assign(j.url);
      return new Promise<never>(() => {});
    }
    
    /* ───────────────────────────── promo codes ───────────────────────────── */
    
    export type PromoQuote = { valid: true; code: string; description: string; discountCents: number; trialDays: number; amountCents: number; currency: string };
    
    /**
     * What a code is worth against these items, before checkout. Throws with a message you can show as it is
     * ("That code has expired", "That code doesn't apply to anything in this order"…). Then pass the code to
     * checkout(items, { code }); the server works the discount out again there, so nothing here is trusted.
     *
     *   const q = await checkPromo(code, items);   // { description: "20% off", discountCents: 980, amountCents: 3920, … }
     */
    export async function checkPromo(code: string, items: { product: string; quantity?: number; amount?: number }[], email?: string): Promise<PromoQuote> {
      return pay<PromoQuote>({ action: "promo", code, items, ...(email ? { customer_email: email } : {}) });
    }
    
  • collections

    Declares shared collections and their rules (public | members | inbox) for useShared; Ripping reads this file.

    src/lib/collections.ts

    /**
     * Collections other people can see, and who can do what with them. Ripping reads this file to enforce the rules.
     *   public:  anyone can read; signed-in people add; each person edits or deletes their own (posts, reviews, listings)
     *   members: signed-in people read everything and add; each person edits or deletes their own (a team board)
     *   inbox:   signed-in people add; each person sees only their own; the app's admins see all (bookings, orders, requests)
     * The app's owner makes someone an admin in Ripping's Users tab. Private per-person data uses useStore instead.
     *   export const COLLECTIONS = { reviews: "public", bookings: "inbox" } as const satisfies Record<string, Rule>;
     */
    export type Rule = "public" | "members" | "inbox" | "team";
    export const COLLECTIONS = {} as const satisfies Record<string, Rule>;
    
  • store

    A typed localStorage collection hook: list, add, update, remove, undo, export/import JSON. Adapt the record type.

    src/lib/store.ts

    import { useCallback, useEffect, useRef, useState } from "react";
    
    export type WithId = { id: string; createdAt: string };
    /** useStore<Habit>("habits") keeps an array of records in localStorage with undo and JSON export. */
    export function useStore<T extends WithId>(key: string, seed: T[] = []) {
      const [items, setItems] = useState<T[]>(() => { try { const raw = localStorage.getItem(key); return raw ? (JSON.parse(raw) as T[]) : seed; } catch { return seed; } });
      const history = useRef<T[][]>([]);
      useEffect(() => { try { localStorage.setItem(key, JSON.stringify(items)); } catch { /* storage full or blocked */ } }, [key, items]);
      const commit = useCallback((next: (prev: T[]) => T[]) => setItems((prev) => { history.current = [...history.current.slice(-19), prev]; return next(prev); }), []);
      const add = useCallback((data: Omit<T, "id" | "createdAt">) => { const rec = { ...data, id: crypto.randomUUID(), createdAt: new Date().toISOString() } as T; commit((p) => [rec, ...p]); return rec; }, [commit]);
      const update = useCallback((id: string, patch: Partial<T>) => commit((p) => p.map((x) => (x.id === id ? { ...x, ...patch } : x))), [commit]);
      const remove = useCallback((id: string) => commit((p) => p.filter((x) => x.id !== id)), [commit]);
      const undo = useCallback(() => { const prev = history.current.pop(); if (prev) setItems(prev); }, []);
      const exportJson = useCallback(() => JSON.stringify(items, null, 2), [items]);
      const importJson = useCallback((json: string) => { const parsed = JSON.parse(json) as T[]; if (Array.isArray(parsed)) commit(() => parsed); }, [commit]);
      return { items, add, update, remove, undo, canUndo: history.current.length > 0, exportJson, importJson };
    }
  • supabase-store

    The same collection API over a Supabase table (needs @supabase/supabase-js and the connected project's url + anon key).

    src/lib/supabaseStore.ts

    import { useCallback, useEffect, useState } from "react";
    import { createClient, type SupabaseClient } from "@supabase/supabase-js";
    
    let client: SupabaseClient | null = null;
    export const supabase = (url: string, anonKey: string) => (client ??= createClient(url, anonKey));
    
    export type Row = { id: string; created_at: string };
    /** useTable<Habit>(db, "habits") loads a table ordered by created_at and gives add/update/remove with optimistic updates. */
    export function useTable<T extends Row>(db: SupabaseClient, table: string) {
      const [items, setItems] = useState<T[]>([]);
      const [loading, setLoading] = useState(true);
      const [error, setError] = useState<string | null>(null);
      const load = useCallback(async () => { setLoading(true); const { data, error } = await db.from(table).select("*").order("created_at", { ascending: false }); if (error) setError(error.message); else setItems((data ?? []) as T[]); setLoading(false); }, [db, table]);
      useEffect(() => { void load(); }, [load]);
      const add = useCallback(async (data: Omit<T, "id" | "created_at">) => { const { data: row, error } = await db.from(table).insert(data).select("*").single(); if (error) { setError(error.message); return null; } setItems((p) => [row as T, ...p]); return row as T; }, [db, table]);
      const update = useCallback(async (id: string, patch: Partial<T>) => { setItems((p) => p.map((x) => (x.id === id ? { ...x, ...patch } : x))); const { error } = await db.from(table).update(patch).eq("id", id); if (error) { setError(error.message); void load(); } }, [db, table, load]);
      const remove = useCallback(async (id: string) => { setItems((p) => p.filter((x) => x.id !== id)); const { error } = await db.from(table).delete().eq("id", id); if (error) { setError(error.message); void load(); } }, [db, table, load]);
      return { items, loading, error, add, update, remove, reload: load };
    }
  • supabase-auth

    Only when the person asks for Supabase auth specifically: magic-link sign-in over their connected Supabase project: a SignIn screen, a useSession hook, and a sign-out button.

    src/lib/auth.tsx

    import { useEffect, useState } from "react";
    import type { Session, SupabaseClient } from "@supabase/supabase-js";
    
    export function useSession(db: SupabaseClient) {
      const [session, setSession] = useState<Session | null | undefined>(undefined);
      useEffect(() => { db.auth.getSession().then(({ data }) => setSession(data.session)); const { data: sub } = db.auth.onAuthStateChange((_e, s) => setSession(s)); return () => sub.subscription.unsubscribe(); }, [db]);
      return { session, loading: session === undefined, signOut: () => db.auth.signOut() };
    }
    
    export function SignIn({ db, appName }: { db: SupabaseClient; appName: string }) {
      const [email, setEmail] = useState(""); const [sent, setSent] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
      return (
        <main className="flex min-h-screen items-center justify-center bg-bg p-6">
          <form className="w-full max-w-sm rounded-card bg-surface p-6 shadow-sm" onSubmit={async (e) => { e.preventDefault(); setBusy(true); setError(null); const { error } = await db.auth.signInWithOtp({ email, options: { emailRedirectTo: window.location.origin } }); setBusy(false); if (error) setError(error.message); else setSent(true); }}>
            <h1 className="text-xl font-semibold text-ink">{appName}</h1>
            {sent ? <p className="mt-3 text-sm text-mute">Check your email for a sign-in link.</p> : <>
              <p className="mt-1 text-sm text-mute">Sign in with a link sent to your email.</p>
              <input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" className="mt-4 h-11 w-full rounded-card border border-mute/30 bg-bg px-3 text-base text-ink outline-none focus:border-accent" />
              {error && <p className="mt-2 text-xs text-red-600">{error}</p>}
              <button type="submit" disabled={busy} className="mt-3 h-11 w-full rounded-card bg-accent text-sm font-medium text-accent-fg disabled:opacity-60">{busy ? "Sending…" : "Send me a link"}</button>
            </>}
          </form>
        </main>
      );
    }
  • list

    A typed list view: search box, sort menu, filter chips, empty state and simple pagination. Adapt the record type and the row.

    src/components/ListView.tsx

    import { useMemo, useState, type ReactNode } from "react";
    import { Search } from "lucide-react";
    
    export type SortOption<T> = { key: string; label: string; by: (a: T, b: T) => number };
    export type Filter<T> = { key: string; label: string; test: (x: T) => boolean };
    export function ListView<T extends { id: string }>({ items, row, search, sorts, filters, pageSize = 20, empty }: { items: T[]; row: (x: T) => ReactNode; search: (x: T) => string; sorts: SortOption<T>[]; filters?: Filter<T>[]; pageSize?: number; empty: ReactNode }) {
      const [q, setQ] = useState(""); const [sort, setSort] = useState(sorts[0]?.key); const [active, setActive] = useState<string[]>([]); const [page, setPage] = useState(0);
      const shown = useMemo(() => { const needle = q.trim().toLowerCase(); const s = sorts.find((x) => x.key === sort) ?? sorts[0]; const fs = (filters ?? []).filter((f) => active.includes(f.key)); return items.filter((x) => (!needle || search(x).toLowerCase().includes(needle)) && fs.every((f) => f.test(x))).sort(s ? s.by : () => 0); }, [items, q, sort, active, sorts, filters, search]);
      const pages = Math.max(1, Math.ceil(shown.length / pageSize)); const slice = shown.slice(page * pageSize, page * pageSize + pageSize);
      return (
        <div className="flex flex-col gap-3">
          <div className="flex flex-wrap items-center gap-2">
            <label className="relative flex-1"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-mute" /><input value={q} onChange={(e) => { setQ(e.target.value); setPage(0); }} placeholder="Search" className="h-11 w-full rounded-card border border-mute/30 bg-surface pl-10 pr-3 text-base text-ink outline-none focus:border-accent" /></label>
            {sorts.length > 1 && <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort" className="h-11 rounded-card border border-mute/30 bg-surface px-3 text-sm text-ink">{sorts.map((s) => <option key={s.key} value={s.key}>{s.label}</option>)}</select>}
          </div>
          {filters && filters.length > 0 && <div className="flex flex-wrap gap-2">{filters.map((f) => { const on = active.includes(f.key); return <button key={f.key} type="button" aria-pressed={on} onClick={() => { setActive(on ? active.filter((k) => k !== f.key) : [...active, f.key]); setPage(0); }} className={"h-9 rounded-full border px-3 text-sm " + (on ? "border-accent bg-accent text-accent-fg" : "border-mute/30 bg-surface text-ink")}>{f.label}</button>; })}</div>}
          {shown.length === 0 ? empty : <ul className="flex flex-col gap-2">{slice.map((x) => <li key={x.id}>{row(x)}</li>)}</ul>}
          {pages > 1 && <div className="flex items-center justify-between text-sm text-mute"><button type="button" disabled={page === 0} onClick={() => setPage(page - 1)} className="h-9 rounded-card px-3 disabled:opacity-40">Previous</button><span>{page + 1} of {pages}</span><button type="button" disabled={page >= pages - 1} onClick={() => setPage(page + 1)} className="h-9 rounded-card px-3 disabled:opacity-40">Next</button></div>}
        </div>
      );
    }
  • settings

    A settings screen: profile, preferences (theme, density), data export/import as JSON, and delete everything with a confirm. Wire exportJson/importJson from the store.

    src/components/SettingsScreen.tsx

    import { useEffect, useState } from "react";
    import { Download, Moon, Sun, Monitor, Trash2, Upload } from "lucide-react";
    import { useToast } from "@ripping/kit";
    
    type Theme = "light" | "dark" | "system";
    type Prefs = { theme: Theme; compact: boolean };
    type Profile = { name: string; email: string };
    
    /** A tiny persisted setting: useSetting<Prefs>("prefs", { theme: "system", compact: false }). */
    export function useSetting<T>(key: string, initial: T): [T, (next: T) => void] {
      const [v, setV] = useState<T>(() => { try { const raw = localStorage.getItem(key); return raw ? (JSON.parse(raw) as T) : initial; } catch { return initial; } });
      useEffect(() => { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* blocked */ } }, [key, v]);
      return [v, setV];
    }
    
    /** Applies the theme to <html class="dark">; call once near the root. */
    export function useTheme(theme: Theme) {
      useEffect(() => {
        const mq = window.matchMedia("(prefers-color-scheme: dark)");
        const apply = () => document.documentElement.classList.toggle("dark", theme === "dark" || (theme === "system" && mq.matches));
        apply(); mq.addEventListener("change", apply); return () => mq.removeEventListener("change", apply);
      }, [theme]);
    }
    
    export function SettingsScreen({ exportJson, importJson, onDeleteAll }: { exportJson: () => string; importJson: (json: string) => void; onDeleteAll: () => void }) {
      const toast = useToast();
      const [profile, setProfile] = useSetting<Profile>("profile", { name: "", email: "" });
      const [prefs, setPrefs] = useSetting<Prefs>("prefs", { theme: "system", compact: false });
      const [confirming, setConfirming] = useState(false);
      useTheme(prefs.theme);
      const download = () => { const blob = new Blob([exportJson()], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "export.json"; a.click(); URL.revokeObjectURL(url); toast("Exported"); };
      const upload = (f: File | undefined) => { if (!f) return; f.text().then((t) => { try { importJson(t); toast("Imported"); } catch { toast("That file isn't valid JSON"); } }); };
      const themes: { id: Theme; label: string; Icon: typeof Sun }[] = [{ id: "light", label: "Light", Icon: Sun }, { id: "dark", label: "Dark", Icon: Moon }, { id: "system", label: "System", Icon: Monitor }];
      return (
        <div className="mx-auto flex max-w-xl flex-col gap-6 p-gutter">
          <section className="rounded-card border border-ink/10 bg-surface p-4">
            <h2 className="text-sm font-semibold">Profile</h2>
            <label className="mt-3 block text-xs text-mute">Name<input value={profile.name} onChange={(e) => setProfile({ ...profile, name: e.target.value })} className="mt-1 h-11 w-full rounded-lg border border-ink/10 bg-bg px-3 text-sm text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent" /></label>
            <label className="mt-3 block text-xs text-mute">Email<input type="email" value={profile.email} onChange={(e) => setProfile({ ...profile, email: e.target.value })} className="mt-1 h-11 w-full rounded-lg border border-ink/10 bg-bg px-3 text-sm text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent" /></label>
          </section>
          <section className="rounded-card border border-ink/10 bg-surface p-4">
            <h2 className="text-sm font-semibold">Preferences</h2>
            <div role="radiogroup" aria-label="Theme" className="mt-3 flex gap-2">
              {themes.map(({ id, label, Icon }) => <button key={id} type="button" role="radio" aria-checked={prefs.theme === id} onClick={() => setPrefs({ ...prefs, theme: id })} className={`inline-flex h-11 items-center gap-1.5 rounded-lg border px-3 text-sm ${prefs.theme === id ? "border-accent bg-accent text-accent-fg" : "border-ink/10 text-ink"}`}><Icon className="size-4" /> {label}</button>)}
            </div>
            <label className="mt-3 flex h-11 items-center gap-3 text-sm"><input type="checkbox" checked={prefs.compact} onChange={(e) => setPrefs({ ...prefs, compact: e.target.checked })} className="size-4 accent-accent" /> Compact lists</label>
          </section>
          <section className="rounded-card border border-ink/10 bg-surface p-4">
            <h2 className="text-sm font-semibold">Your data</h2>
            <p className="mt-1 text-xs text-mute">Everything is stored in this browser. Export a copy any time, or bring one back.</p>
            <div className="mt-3 flex flex-wrap gap-2">
              <button type="button" onClick={download} className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-ink/10 px-3 text-sm"><Download className="size-4" /> Export JSON</button>
              <label className="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-lg border border-ink/10 px-3 text-sm"><Upload className="size-4" /> Import JSON<input type="file" accept="application/json" className="sr-only" onChange={(e) => upload(e.target.files?.[0])} /></label>
            </div>
          </section>
          <section className="rounded-card border border-red-500/30 bg-surface p-4">
            <h2 className="text-sm font-semibold text-red-600 dark:text-red-400">Delete everything</h2>
            <p className="mt-1 text-xs text-mute">Removes all records from this browser. There is no undo; export first if unsure.</p>
            {confirming ? (
              <div className="mt-3 flex flex-wrap gap-2">
                <button type="button" onClick={() => { onDeleteAll(); setConfirming(false); toast("Deleted"); }} className="inline-flex h-11 items-center gap-1.5 rounded-lg bg-red-600 px-3 text-sm font-medium text-white"><Trash2 className="size-4" /> Yes, delete it all</button>
                <button type="button" onClick={() => setConfirming(false)} className="inline-flex h-11 items-center rounded-lg border border-ink/10 px-3 text-sm">Keep it</button>
              </div>
            ) : <button type="button" onClick={() => setConfirming(true)} className="mt-3 inline-flex h-11 items-center gap-1.5 rounded-lg border border-red-500/40 px-3 text-sm text-red-600 dark:text-red-400"><Trash2 className="size-4" /> Delete all data</button>}
          </section>
        </div>
      );
    }
  • stat-tiles

    A row of KPI tiles: label, value, delta with colour.

    src/components/StatTiles.tsx

    export function StatTiles({ items }: { items: { label: string; value: string; delta?: number }[] }) {
      return (
        <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
          {items.map((s) => (
            <div key={s.label} className="rounded-card bg-surface p-4 shadow-sm">
              <p className="text-xs font-medium uppercase tracking-wide text-mute">{s.label}</p>
              <p className="mt-1 text-2xl font-semibold tracking-tight text-ink">{s.value}</p>
              {s.delta != null && <p className={"mt-0.5 text-xs " + (s.delta >= 0 ? "text-emerald-600" : "text-red-600")}>{s.delta >= 0 ? "+" : ""}{s.delta}% vs last period</p>}
            </div>
          ))}
        </div>
      );
    }