bake canonical build harness (deterministic /ship for forks) [landing-page]
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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