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> ); }modal
A centered dialog (sheet on phones) with backdrop, Escape to close, focus trap-lite.
src/components/Modal.tsx
import { useEffect, type ReactNode } from "react"; import { X } from "lucide-react"; export function Modal({ open, title, onClose, children }: { open: boolean; title: string; onClose: () => void; children: ReactNode }) { useEffect(() => { if (!open) return; const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [open, onClose]); if (!open) return null; return ( <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4" onClick={onClose} role="dialog" aria-modal="true" aria-label={title}> <div className="w-full max-w-md rounded-t-card bg-surface p-5 shadow-xl sm:rounded-card" onClick={(e) => e.stopPropagation()}> <div className="mb-3 flex items-center justify-between"><h2 className="text-lg font-semibold text-ink">{title}</h2><button type="button" onClick={onClose} aria-label="Close" className="grid size-9 place-items-center rounded-full text-mute hover:bg-bg"><X className="size-4" /></button></div> {children} </div> </div> ); }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> ); }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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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> ); }