bake canonical build harness (deterministic /ship for forks) [fitness]
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// useSaas + useSaasMutation — ready-made SaaS API hooks (tyga.run)
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRE-INSTALLED in every React project. Works for ANY tyga.run SaaS module —
|
||||
// scheduling (bookings/eventtypes/availability), crm, ecommerce, billing,
|
||||
// notifications, support, surveys... CodeWriterAgent just imports:
|
||||
//
|
||||
// import { useSaas, useSaasMutation } from "@/hooks/use-tygarun"
|
||||
// const { data: bookings, loading } = useSaas("scheduling", "/bookings", { query: { status: "confirmed" } })
|
||||
// const { mutate: book, loading: booking } = useSaasMutation("scheduling", "/bookings")
|
||||
// await book({ eventTypeId, customer: { name, email }, scheduledAt })
|
||||
//
|
||||
// Falls back to per-module seed data until tyga.run is connected, so the
|
||||
// generated site looks alive out of the box (same pattern as use-anydb.ts).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import tygarun, { isConfigured, auth } from "@/lib/tygarun";
|
||||
|
||||
// Seed data keyed by "module:endpoint-prefix" — extend freely.
|
||||
const SEED_DATA: Record<string, any> = {
|
||||
"scheduling:/bookings": {
|
||||
bookings: [
|
||||
{ id: "bk1", eventTypeName: "30 Minute Consultation", customer: { name: "Alice Johnson", email: "alice@example.com" }, status: "confirmed", scheduledAt: "2026-07-10T10:00:00Z", duration: 30 },
|
||||
{ id: "bk2", eventTypeName: "Discovery Call", customer: { name: "Bob Smith", email: "bob@example.com" }, status: "pending", scheduledAt: "2026-07-11T14:30:00Z", duration: 15 },
|
||||
{ id: "bk3", eventTypeName: "Strategy Session", customer: { name: "Carol Williams", email: "carol@example.com" }, status: "completed", scheduledAt: "2026-07-02T09:00:00Z", duration: 60 },
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 3, totalPages: 1 },
|
||||
},
|
||||
"scheduling:/eventtypes": {
|
||||
eventTypes: [
|
||||
{ id: "evt1", name: "30 Minute Consultation", slug: "30min-consult", duration: 30, isActive: true, isPublic: true, description: "Quick intro call to discuss your needs." },
|
||||
{ id: "evt2", name: "Strategy Session", slug: "strategy-session", duration: 60, isActive: true, isPublic: true, description: "Deep-dive planning session." },
|
||||
],
|
||||
},
|
||||
"scheduling:/availability": {
|
||||
availability: {
|
||||
timezone: "Europe/London",
|
||||
workingHours: [
|
||||
{ day: "monday", start: "09:00", end: "17:00" },
|
||||
{ day: "tuesday", start: "09:00", end: "17:00" },
|
||||
{ day: "wednesday", start: "09:00", end: "17:00" },
|
||||
{ day: "thursday", start: "09:00", end: "17:00" },
|
||||
{ day: "friday", start: "09:00", end: "15:00" },
|
||||
],
|
||||
},
|
||||
},
|
||||
"crm:/contacts": {
|
||||
contacts: [
|
||||
{ id: "c1", name: "Alice Johnson", email: "alice@example.com", company: "Acme Corp", stage: "customer" },
|
||||
{ id: "c2", name: "Bob Smith", email: "bob@example.com", company: "Globex", stage: "lead" },
|
||||
],
|
||||
},
|
||||
"ecommerce:/products": {
|
||||
products: [
|
||||
{ id: "p1", name: "Classic Leather Jacket", priceCents: 18900, stock: 45, rating: 4.8 },
|
||||
{ id: "p2", name: "Premium Denim Jeans", priceCents: 8900, stock: 120, rating: 4.6 },
|
||||
],
|
||||
},
|
||||
"ecommerce:/catalog": {
|
||||
products: [
|
||||
{ id: "p1", name: "Classic Leather Jacket", priceCents: 18900, stock: 45, rating: 4.8, image: "" },
|
||||
{ id: "p2", name: "Premium Denim Jeans", priceCents: 8900, stock: 120, rating: 4.6, image: "" },
|
||||
{ id: "p3", name: "Merino Wool Sweater", priceCents: 12900, stock: 60, rating: 4.9, image: "" },
|
||||
],
|
||||
},
|
||||
"event-ticketing:/availability": {
|
||||
events: [
|
||||
{ id: "ev1", name: "Live at The Fillmore", date: "2026-08-14T20:00:00Z", venue: "The Fillmore", tiers: [{ id: "t1", name: "General", priceCents: 3500, remaining: 220 }, { id: "t2", name: "VIP", priceCents: 9500, remaining: 30 }] },
|
||||
{ id: "ev2", name: "Summer Sessions", date: "2026-09-02T19:30:00Z", venue: "Open Air Park", tiers: [{ id: "t3", name: "Early Bird", priceCents: 2500, remaining: 0 }, { id: "t4", name: "Standard", priceCents: 4000, remaining: 150 }] },
|
||||
],
|
||||
},
|
||||
"surveys:/": {
|
||||
surveys: [
|
||||
{ id: "sv1", title: "Dark mode", votes: 128, status: "planned", description: "Full dark theme across the app." },
|
||||
{ id: "sv2", title: "Mobile app", votes: 342, status: "in-progress", description: "Native iOS + Android apps." },
|
||||
{ id: "sv3", title: "API access", votes: 87, status: "under-review", description: "Public REST + webhooks." },
|
||||
],
|
||||
},
|
||||
"community:/comments": {
|
||||
comments: [
|
||||
{ id: "cm1", author: "Jamie R.", body: "Would love to see this shipped!", createdAt: "2026-07-01T12:00:00Z" },
|
||||
{ id: "cm2", author: "Priya S.", body: "+1, this blocks our team.", createdAt: "2026-07-03T09:20:00Z" },
|
||||
],
|
||||
},
|
||||
"file-management:/list": {
|
||||
files: [
|
||||
{ id: "f1", name: "gallery-01.jpg", url: "", contentType: "image/jpeg", size: 245000 },
|
||||
{ id: "f2", name: "gallery-02.jpg", url: "", contentType: "image/jpeg", size: 312000 },
|
||||
{ id: "f3", name: "gallery-03.jpg", url: "", contentType: "image/jpeg", size: 198000 },
|
||||
],
|
||||
},
|
||||
"billing:/plans": {
|
||||
plans: [
|
||||
{ id: "starter", name: "Starter", priceCents: 0, interval: "month", features: ["1 project", "Community support"] },
|
||||
{ id: "pro", name: "Pro", priceCents: 2900, interval: "month", features: ["Unlimited projects", "Priority support", "Custom domain"] },
|
||||
{ id: "team", name: "Team", priceCents: 9900, interval: "month", features: ["Everything in Pro", "5 seats", "SSO"] },
|
||||
],
|
||||
},
|
||||
"notifications:/": { notifications: [] },
|
||||
"support:/tickets": { tickets: [] },
|
||||
};
|
||||
|
||||
function seedFor(module: string, endpoint: string): any {
|
||||
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
||||
// Longest matching prefix wins: "scheduling:/bookings/stats" → "scheduling:/bookings"
|
||||
const keys = Object.keys(SEED_DATA)
|
||||
.filter((k) => `${module}:${path}`.startsWith(k))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
return keys.length ? SEED_DATA[keys[0]] : { data: [] };
|
||||
}
|
||||
|
||||
// Extract the payload array/object from a tyga.run response regardless of
|
||||
// envelope key ({ bookings: [...] }, { data: [...] }, plain array...)
|
||||
export function unwrap<T = any>(res: any): T {
|
||||
if (Array.isArray(res)) return res as T;
|
||||
if (res?.data !== undefined) return res.data as T;
|
||||
const arrKey = Object.keys(res || {}).find((k) => Array.isArray(res[k]));
|
||||
return (arrKey ? res[arrKey] : res) as T;
|
||||
}
|
||||
|
||||
interface SaasOptions {
|
||||
query?: Record<string, any>;
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
interface SaasResult<T = any> {
|
||||
data: T;
|
||||
raw: any;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
connected: boolean;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read from ANY tyga.run SaaS module with loading/error states + seed fallback.
|
||||
*
|
||||
* const { data: bookings } = useSaas("scheduling", "/bookings", { query: { status: "confirmed" } })
|
||||
* const { data: eventTypes } = useSaas("scheduling", "/eventtypes")
|
||||
* const { data: contacts } = useSaas("crm", "/contacts")
|
||||
*/
|
||||
export function useSaas<T = any>(
|
||||
module: string,
|
||||
endpoint = "/",
|
||||
options: SaasOptions = {}
|
||||
): SaasResult<T> {
|
||||
const { query = {}, autoFetch = true } = options;
|
||||
const [raw, setRaw] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const connected = isConfigured();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!connected) {
|
||||
setRaw(seedFor(module, endpoint));
|
||||
return;
|
||||
}
|
||||
const res = await tygarun(module, endpoint, { query });
|
||||
setRaw(res);
|
||||
} catch (err: any) {
|
||||
// Connected but call failed → show seed so the UI never breaks
|
||||
setRaw(seedFor(module, endpoint));
|
||||
setError(err.message || "Request failed");
|
||||
console.error(`[useSaas] ${module}${endpoint}:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [module, endpoint, JSON.stringify(query), connected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) fetchData();
|
||||
}, [fetchData, autoFetch]);
|
||||
|
||||
return { data: unwrap<T>(raw), raw, loading, error, connected, refetch: fetchData };
|
||||
}
|
||||
|
||||
interface SaasMutationResult {
|
||||
mutate: (body?: Record<string, any>, endpointOverride?: string) => Promise<any>;
|
||||
update: (id: string, body: Record<string, any>) => Promise<any>;
|
||||
remove: (id: string) => Promise<any>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to ANY tyga.run SaaS module.
|
||||
*
|
||||
* const { mutate: book } = useSaasMutation("scheduling", "/bookings")
|
||||
* await book({ eventTypeId: "evt1", customer: { name, email }, scheduledAt })
|
||||
*
|
||||
* const { mutate: addContact, update, remove } = useSaasMutation("crm", "/contacts")
|
||||
* await update("c1", { stage: "customer" }); await remove("c2")
|
||||
*/
|
||||
export function useSaasMutation(module: string, endpoint = "/"): SaasMutationResult {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const connected = isConfigured();
|
||||
|
||||
const run = useCallback(async (method: "POST" | "PUT" | "DELETE", path: string, body?: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!connected) {
|
||||
// Demo mode — pretend success so forms feel functional pre-connection
|
||||
return { success: true, demo: true, id: `demo_${Date.now()}`, ...(body || {}) };
|
||||
}
|
||||
return await tygarun(module, path, { method, body });
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Request failed");
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [module, connected]);
|
||||
|
||||
const base = endpoint.replace(/\/+$/, "") || "";
|
||||
return {
|
||||
mutate: (body = {}, endpointOverride) => run("POST", endpointOverride || endpoint, body),
|
||||
update: (id, body) => run("PUT", `${base}/${id}`, body),
|
||||
remove: (id) => run("DELETE", `${base}/${id}`),
|
||||
loading,
|
||||
error,
|
||||
connected,
|
||||
};
|
||||
}
|
||||
|
||||
// ── useAuth — capability: login (tyga.run auth module) ──────────────────────
|
||||
// Reactive wrapper over lib/tygarun's auth object. Gates protected UI (voting,
|
||||
// booking, checkout) behind sign-in. Demo mode: any signin "succeeds" with a
|
||||
// fake user so gated flows are explorable pre-connection.
|
||||
interface AuthUser { email: string; firstName?: string; lastName?: string; [k: string]: any }
|
||||
interface AuthResult {
|
||||
user: AuthUser | null;
|
||||
isSignedIn: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
connected: boolean;
|
||||
signup: (data: { email: string; password: string; [k: string]: any }) => Promise<any>;
|
||||
signin: (email: string, password: string, rememberMe?: boolean) => Promise<any>;
|
||||
signout: () => void;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthResult {
|
||||
const connected = isConfigured();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Rehydrate: a stored token means a prior sign-in this session.
|
||||
if (auth.isSignedIn() && !user) setUser({ email: "member@example.com" });
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const wrap = useCallback(async (fn: () => Promise<any>, demoUser: AuthUser) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!connected) { setUser(demoUser); return { success: true, demo: true, user: demoUser }; }
|
||||
const res = await fn();
|
||||
setUser(res?.user || { email: demoUser.email });
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Authentication failed");
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isSignedIn: Boolean(user) || auth.isSignedIn(),
|
||||
loading,
|
||||
error,
|
||||
connected,
|
||||
signup: (data) => wrap(() => auth.signup(data), { email: data.email, firstName: data.firstName, lastName: data.lastName }),
|
||||
signin: (email, password, rememberMe = false) => wrap(() => auth.signin(email, password, rememberMe), { email }),
|
||||
signout: () => { auth.signout(); setUser(null); },
|
||||
};
|
||||
}
|
||||
|
||||
// ── useCart — capability: store (client-side cart + tyga.run checkout) ───────
|
||||
// Cart lives in React state (+ localStorage); checkout hits ecommerce. Demo
|
||||
// mode returns a fake checkout URL so the flow is walkable pre-connection.
|
||||
export interface CartItem { id: string; name: string; priceCents: number; quantity: number; [k: string]: any }
|
||||
|
||||
export function useCart() {
|
||||
const connected = isConfigured();
|
||||
const CART_KEY = "tygarun_cart";
|
||||
const [items, setItems] = useState<CartItem[]>(() => {
|
||||
try { return JSON.parse(localStorage.getItem(CART_KEY) || "[]"); } catch { return []; }
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const persist = useCallback((next: CartItem[]) => {
|
||||
setItems(next);
|
||||
try { localStorage.setItem(CART_KEY, JSON.stringify(next)); } catch { /* SSR */ }
|
||||
}, []);
|
||||
|
||||
const add = useCallback((item: Omit<CartItem, "quantity">, qty = 1) => {
|
||||
persist((() => {
|
||||
const existing = items.find((i) => i.id === item.id);
|
||||
return existing
|
||||
? items.map((i) => (i.id === item.id ? { ...i, quantity: i.quantity + qty } : i))
|
||||
: [...items, { ...item, quantity: qty }];
|
||||
})());
|
||||
}, [items, persist]);
|
||||
|
||||
const remove = useCallback((id: string) => persist(items.filter((i) => i.id !== id)), [items, persist]);
|
||||
const clear = useCallback(() => persist([]), [persist]);
|
||||
const count = items.reduce((n, i) => n + i.quantity, 0);
|
||||
const totalCents = items.reduce((n, i) => n + i.priceCents * i.quantity, 0);
|
||||
|
||||
const checkout = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!connected) return { url: "#demo-checkout", demo: true };
|
||||
return await tygarun("ecommerce", "/checkout", { method: "POST", body: { items } });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connected, items]);
|
||||
|
||||
return { items, count, totalCents, add, remove, clear, checkout, loading, connected };
|
||||
}
|
||||
Reference in New Issue
Block a user