// One System content helper — v1 (https://copilot.onesystemcrm.com/developers) // // Copy this file into your project as onesystem/content.ts. It loads the site's editable content (texts, links, // images, Google titles) from One System, so the business can change it by chat with a preview and Undo. // If One System doesn't answer quickly, the copy built into your site (public/onesystem-content.json) is used, // so pages never go blank. Works in the browser (React / Vite) and on the server (Next.js). export type ContentItem = { value: string; label?: string; type?: string; page?: string }; export type ContentFile = { version: 1; pages?: { path: string; name: string }[]; fields: Record }; export type Content = Record; export const ONESYSTEM_API = "https://copilot.onesystemcrm.com"; const SITE_ID = /^os_[a-z0-9]{10}$/; const TOKEN = /^[A-Za-z0-9_-]{20,100}$/; /** The values built into the site (the fallback). */ export function bundledValues(file: ContentFile): Content { const out: Content = {}; for (const [key, item] of Object.entries(file.fields ?? {})) out[key] = typeof item?.value === "string" ? item.value : ""; return out; } /** The preview token in the address bar (?os_preview=…), only in the browser. */ export function previewToken(search?: string): string | null { const s = search ?? (typeof window === "undefined" ? "" : window.location.search); const t = new URLSearchParams(s).get("os_preview"); return t && TOKEN.test(t) ? t : null; } export type LoadOptions = { /** The Site ID from One System (Settings → Website), e.g. import.meta.env.VITE_ONESYSTEM_SITE or process.env.NEXT_PUBLIC_ONESYSTEM_SITE */ site?: string; /** Your public/onesystem-content.json (imported), used as the fallback and to know which items exist */ file: ContentFile; /** A preview token: shows a change that is waiting for the owner's approval */ preview?: string | null; /** How long to wait for One System before using the built-in copy (default 1500 ms) */ timeoutMs?: number; /** Next.js: e.g. { next: { revalidate: 30 } } */ init?: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } }; api?: string; }; /** The live content (or the built-in copy when One System can't be reached). Never throws. */ export async function loadContent(opts: LoadOptions): Promise { const fallback = bundledValues(opts.file); if (!opts.site || !SITE_ID.test(opts.site)) return fallback; const preview = opts.preview && TOKEN.test(opts.preview) ? opts.preview : null; const url = `${(opts.api ?? ONESYSTEM_API).replace(/\/+$/, "")}/api/site-content/${opts.site}${preview ? `?preview=${encodeURIComponent(preview)}` : ""}`; const ctl = typeof AbortController === "undefined" ? undefined : new AbortController(); const timer = setTimeout(() => ctl?.abort(), opts.timeoutMs ?? 1500); try { const init: RequestInit & { next?: unknown } = { ...opts.init, signal: ctl?.signal }; if (preview) { init.cache = "no-store"; delete init.next; } const res = await fetch(url, init); if (!res.ok) return fallback; const body = (await res.json()) as { values?: Record }; const live: Content = {}; for (const [key, value] of Object.entries(body?.values ?? {})) if (typeof value === "string" && key in fallback) live[key] = value; return { ...fallback, ...live }; } catch { return fallback; } finally { clearTimeout(timer); } }