bake canonical build harness (deterministic /ship for forks) [landing-page]

This commit is contained in:
vibeasite-bot
2026-08-22 06:20:56 +01:00
parent 246355ee64
commit 4e06407d69
77 changed files with 6567 additions and 247 deletions
+292
View File
@@ -0,0 +1,292 @@
// ---------------------------------------------------------------------------
// useCollection + useMutation — pre-built AnyDB data hooks
// ---------------------------------------------------------------------------
// PRE-INSTALLED in every AUTO_DB React project. CodeWriterAgent just imports:
// import { useCollection, useMutation } from "@/hooks/use-anydb"
// const { data, loading, error, refetch } = useCollection('products', { sort: { created_at: -1 }, limit: 20 })
// const { mutate, loading: saving } = useMutation('products')
// ---------------------------------------------------------------------------
import { useState, useEffect, useCallback } from "react";
// AnyDB import — always available (stub if not connected, real client if connected)
import anydb from "@/lib/anydb";
// Seed data fallback — pre-populated per category so the site looks functional
// even before AnyDB is connected. Replace with real data by connecting AnyDB.
const SEED_DATA: Record<string, any[]> = {
users: [
{ _id: "u1", name: "Alice Johnson", email: "alice@example.com", plan: "pro", role: "admin", avatar_url: "", created_at: "2025-01-15T10:00:00Z" },
{ _id: "u2", name: "Bob Smith", email: "bob@example.com", plan: "free", role: "user", avatar_url: "", created_at: "2025-02-20T14:30:00Z" },
{ _id: "u3", name: "Carol Williams", email: "carol@example.com", plan: "enterprise", role: "user", avatar_url: "", created_at: "2025-03-10T09:15:00Z" },
],
products: [
{ _id: "p1", name: "Classic Leather Jacket", price_cents: 18900, category: "clothing", stock: 45, rating: 4.8, created_at: "2025-01-10T00:00:00Z" },
{ _id: "p2", name: "Premium Denim Jeans", price_cents: 8900, category: "clothing", stock: 120, rating: 4.6, created_at: "2025-01-15T00:00:00Z" },
{ _id: "p3", name: "Cashmere Sweater", price_cents: 12900, category: "clothing", stock: 30, rating: 4.9, created_at: "2025-02-01T00:00:00Z" },
{ _id: "p4", name: "Silk Blend Shirt", price_cents: 6900, category: "clothing", stock: 85, rating: 4.5, created_at: "2025-02-15T00:00:00Z" },
],
orders: [
{ _id: "o1", customer: "Alice Johnson", total_cents: 28900, status: "completed", created_at: "2025-03-01T10:00:00Z" },
{ _id: "o2", customer: "Bob Smith", total_cents: 8900, status: "processing", created_at: "2025-03-05T14:00:00Z" },
{ _id: "o3", customer: "Carol Williams", total_cents: 45800, status: "completed", created_at: "2025-03-08T09:00:00Z" },
{ _id: "o4", customer: "David Lee", total_cents: 12900, status: "pending", created_at: "2025-03-10T16:00:00Z" },
{ _id: "o5", customer: "Eve Chen", total_cents: 6900, status: "cancelled", created_at: "2025-03-12T11:00:00Z" },
],
metrics: [
{ _id: "m1", name: "revenue", value: 48500, unit: "dollars", recorded_at: "2025-03-01T00:00:00Z" },
{ _id: "m2", name: "users", value: 2340, unit: "count", recorded_at: "2025-03-01T00:00:00Z" },
{ _id: "m3", name: "conversion", value: 3.2, unit: "percent", recorded_at: "2025-03-01T00:00:00Z" },
{ _id: "m4", name: "uptime", value: 99.99, unit: "percent", recorded_at: "2025-03-01T00:00:00Z" },
],
posts: [
{ _id: "post1", title: "Getting Started with Our Platform", slug: "getting-started", excerpt: "Learn how to set up your account and start building.", category: "Tutorial", author_id: "u1", status: "published", published_at: "2025-02-15T10:00:00Z" },
{ _id: "post2", title: "Advanced Analytics Deep Dive", slug: "analytics-deep-dive", excerpt: "Explore powerful analytics features for data-driven decisions.", category: "Technology", author_id: "u1", status: "published", published_at: "2025-03-01T10:00:00Z" },
{ _id: "post3", title: "Best Practices for Team Collaboration", slug: "team-collaboration", excerpt: "Tips for getting the most out of collaborative features.", category: "Business", author_id: "u2", status: "published", published_at: "2025-03-10T10:00:00Z" },
],
subscribers: [],
contact_messages: [],
};
// Seed-data collection simulator
function createSeedCollection(name: string) {
const data = SEED_DATA[name] || [];
return {
find: async (filter: any = {}, opts: any = {}) => {
let results = [...data];
// Basic filter
if (Object.keys(filter).length > 0) {
results = results.filter(item =>
Object.entries(filter).every(([k, v]) => item[k] === v)
);
}
// Sort
if (opts.sort) {
const [key, dir] = Object.entries(opts.sort)[0] as [string, number];
results.sort((a, b) => dir === 1 ? (a[key] > b[key] ? 1 : -1) : (a[key] < b[key] ? 1 : -1));
}
const total = results.length;
// Pagination
if (opts.skip) results = results.slice(opts.skip);
if (opts.limit) results = results.slice(0, opts.limit);
return { data: results, total, page: 1, limit: opts.limit || 50, pages: 1 };
},
insertOne: async (doc: any) => {
const newDoc = { ...doc, _id: `seed_${Date.now()}` };
data.push(newDoc);
return newDoc;
},
updateOne: async (filter: any, update: any) => {
const idx = data.findIndex(d => Object.entries(filter).every(([k, v]) => d[k] === v));
if (idx >= 0) Object.assign(data[idx], update);
return { modifiedCount: idx >= 0 ? 1 : 0 };
},
deleteOne: async (filter: any) => {
const idx = data.findIndex(d => Object.entries(filter).every(([k, v]) => d[k] === v));
if (idx >= 0) data.splice(idx, 1);
return { deletedCount: idx >= 0 ? 1 : 0 };
},
aggregate: async (pipeline: any[]) => {
// Basic count aggregation support
if (pipeline.some(s => s.$count)) return [{ value: data.length }];
if (pipeline.some(s => s.$group)) {
const group = pipeline.find(s => s.$group);
if (group.$group._id === null) {
const sumField = Object.entries(group.$group).find(([k, v]: any) => v?.$sum);
if (sumField) {
const field = (sumField[1] as any).$sum?.replace('$', '');
const total = data.reduce((acc, d) => acc + (d[field] || 0), 0);
return [{ value: total }];
}
return [{ value: data.length }];
}
}
return data;
},
};
}
// Unified collection getter — tries AnyDB first, falls back to seed data
async function getCollection(name: string) {
try {
const col = await anydb.collection(name);
// Test if it's a real connection (stub returns empty instantly)
const test = await col.find({}, { limit: 1 });
if (test && test.data !== undefined) return col;
} catch {
// AnyDB not connected — use seed data
}
return createSeedCollection(name);
}
interface CollectionOptions {
filter?: Record<string, any>;
sort?: Record<string, 1 | -1>;
limit?: number;
skip?: number;
autoFetch?: boolean;
}
interface CollectionResult<T = any> {
data: T[];
total: number;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
/**
* Fetch data from an AnyDB collection with loading/error states.
*
* Usage:
* const { data: products, loading, refetch } = useCollection('products', { limit: 20 })
* const { data: orders } = useCollection('orders', { filter: { status: 'active' }, sort: { created_at: -1 } })
*/
export function useCollection<T = any>(
collectionName: string,
options: CollectionOptions = {}
): CollectionResult<T> {
const { filter = {}, sort, limit = 50, skip = 0, autoFetch = true } = options;
const [data, setData] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const col = await getCollection(collectionName);
const result = await col.find(filter, { sort, limit, skip });
setData((result.data as T[]) || []);
setTotal(result.total || 0);
} catch (err: any) {
setError(err.message || "Failed to fetch data");
console.error(`[useCollection] ${collectionName}:`, err);
} finally {
setLoading(false);
}
}, [collectionName, JSON.stringify(filter), JSON.stringify(sort), limit, skip]);
useEffect(() => {
if (autoFetch) fetchData();
}, [fetchData, autoFetch]);
return { data, total, loading, error, refetch: fetchData };
}
interface MutationResult {
mutate: (document: Record<string, any>) => Promise<any>;
update: (filter: Record<string, any>, updates: Record<string, any>) => Promise<any>;
remove: (filter: Record<string, any>) => Promise<any>;
loading: boolean;
error: string | null;
}
/**
* Write to an AnyDB collection (insert, update, delete).
*
* Usage:
* const { mutate, loading } = useMutation('contact_messages')
* await mutate({ name, email, message }) // insertOne
* await update({ _id: id }, { status: 'read' }) // updateOne
* await remove({ _id: id }) // deleteOne
*/
export function useMutation(collectionName: string): MutationResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mutate = useCallback(async (document: Record<string, any>) => {
setLoading(true);
setError(null);
try {
const col = await getCollection(collectionName);
const result = await col.insertOne({
...document,
created_at: new Date().toISOString(),
});
return result;
} catch (err: any) {
setError(err.message || "Failed to save");
throw err;
} finally {
setLoading(false);
}
}, [collectionName]);
const update = useCallback(async (filter: Record<string, any>, updates: Record<string, any>) => {
setLoading(true);
setError(null);
try {
const col = await getCollection(collectionName);
const result = await col.updateOne(filter, updates);
return result;
} catch (err: any) {
setError(err.message || "Failed to update");
throw err;
} finally {
setLoading(false);
}
}, [collectionName]);
const remove = useCallback(async (filter: Record<string, any>) => {
setLoading(true);
setError(null);
try {
const col = await getCollection(collectionName);
const result = await col.deleteOne(filter);
return result;
} catch (err: any) {
setError(err.message || "Failed to delete");
throw err;
} finally {
setLoading(false);
}
}, [collectionName]);
return { mutate, update, remove, loading, error };
}
interface AggregateResult<T = any> {
data: T[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
/**
* Run an aggregation pipeline on an AnyDB collection.
*
* Usage:
* const { data: stats } = useAggregate('orders', [
* { $group: { _id: '$status', count: { $sum: 1 }, total: { $sum: '$total_cents' } } }
* ])
*/
export function useAggregate<T = any>(
collectionName: string,
pipeline: Record<string, any>[]
): AggregateResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const col = await getCollection(collectionName);
const result = await col.aggregate(pipeline);
setData((result as T[]) || []);
} catch (err: any) {
setError(err.message || "Aggregation failed");
} finally {
setLoading(false);
}
}, [collectionName, JSON.stringify(pipeline)]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+161
View File
@@ -0,0 +1,161 @@
// ---------------------------------------------------------------------------
// useChannel + usePresence + useHistory — pre-built OddSockets realtime hooks
// ---------------------------------------------------------------------------
// PRE-INSTALLED in every React project (realtime leg of the tyga backend
// bundle). Same stub->real-client-on-connect pattern as use-anydb/use-tygarun:
//
// import { useChannel, usePresence, useHistory } from "@/hooks/use-oddsockets"
// const { messages, publish, connected } = useChannel("room:lobby")
// const { members, updateState } = usePresence("room:lobby")
// const { history, loading } = useHistory("room:lobby", { count: 50 })
//
// Until OddSockets is connected, hooks run against the local demo bus in
// lib/oddsockets.ts so realtime UIs render live. `connected` reflects
// isConfigured() and `error` surfaces a broken real connect — the demo state
// never silently masks a bad key.
// ---------------------------------------------------------------------------
import { useState, useEffect, useCallback, useRef } from "react";
import oddsockets, { isConfigured, type ChannelMessage, type PresenceMember } from "@/lib/oddsockets";
interface ChannelResult {
messages: ChannelMessage[];
publish: (data: any) => Promise<void>;
connected: boolean;
error: string | null;
}
/**
* Subscribe to a realtime channel. Accumulates incoming messages and exposes a
* publish() for sending. `connected` is true only when OddSockets is really
* connected (false in demo mode).
*/
export function useChannel(channelName: string): ChannelResult {
const [messages, setMessages] = useState<ChannelMessage[]>([]);
const [error, setError] = useState<string | null>(null);
const channelRef = useRef(oddsockets.channel(channelName));
useEffect(() => {
const ch = oddsockets.channel(channelName);
channelRef.current = ch;
let off = () => {};
try {
off = ch.subscribe((msg) => setMessages((prev) => [...prev, msg]));
} catch (err: any) {
setError(err.message || "Failed to subscribe");
console.error(`[useChannel] ${channelName}:`, err);
}
return () => {
try { off(); } catch { /* noop */ }
};
}, [channelName]);
const publish = useCallback(async (data: any) => {
setError(null);
try {
await channelRef.current.publish(data);
} catch (err: any) {
setError(err.message || "Failed to publish");
throw err;
}
}, []);
return { messages, publish, connected: isConfigured(), error };
}
interface PresenceResult {
members: PresenceMember[];
updateState: (state: Record<string, any>) => Promise<void>;
connected: boolean;
error: string | null;
}
/**
* Track presence on a channel (who's here + their state), with updateState()
* to publish this client's own state (e.g. { typing: true }).
*/
export function usePresence(channelName: string): PresenceResult {
const [members, setMembers] = useState<PresenceMember[]>([]);
const [error, setError] = useState<string | null>(null);
const channelRef = useRef(oddsockets.channel(channelName));
const refresh = useCallback(async () => {
try {
const list = await channelRef.current.getPresence();
setMembers(list || []);
} catch (err: any) {
setError(err.message || "Failed to read presence");
}
}, []);
useEffect(() => {
const ch = oddsockets.channel(channelName);
channelRef.current = ch;
let off = () => {};
try {
// re-read presence on every channel message (join/leave/state changes)
off = ch.subscribe(() => { refresh(); });
} catch (err: any) {
setError(err.message || "Failed to subscribe to presence");
}
refresh();
return () => {
try { off(); } catch { /* noop */ }
};
}, [channelName, refresh]);
const updateState = useCallback(async (state: Record<string, any>) => {
setError(null);
try {
await channelRef.current.updateState(state);
await refresh();
} catch (err: any) {
setError(err.message || "Failed to update state");
throw err;
}
}, [refresh]);
return { members, updateState, connected: isConfigured(), error };
}
interface HistoryResult {
history: ChannelMessage[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
/**
* Fetch recent messages for a channel (backlog), with loading/error states.
*/
export function useHistory(
channelName: string,
options: { count?: number; start?: string; end?: string } = {}
): HistoryResult {
const { count = 50, start, end } = options;
const [history, setHistory] = useState<ChannelMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchHistory = useCallback(async () => {
setLoading(true);
setError(null);
try {
const ch = oddsockets.channel(channelName);
const result = await ch.getHistory({ count, start, end });
setHistory(result || []);
} catch (err: any) {
setError(err.message || "Failed to fetch history");
console.error(`[useHistory] ${channelName}:`, err);
} finally {
setLoading(false);
}
}, [channelName, count, start, end]);
useEffect(() => {
fetchHistory();
}, [fetchHistory]);
return { history, loading, error, refetch: fetchHistory };
}
+186
View File
@@ -0,0 +1,186 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
+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 };
}