bake canonical build harness (deterministic /ship for forks) [product-management]

This commit is contained in:
vibeasite-bot
2026-08-22 06:26:51 +01:00
parent b917c7d1ad
commit 82789e66e3
77 changed files with 6528 additions and 350 deletions
+330
View File
@@ -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 };
}