{ "$schema": "https://ui.shadcn.com/schema/registry.json", "name": "react-factories", "homepage": "https://github.com/the-corner-inc/react-factories", "items": [ { "name": "cn", "type": "registry:lib", "title": "cn Utility", "description": "Class name utility combining clsx and tailwind-merge.", "files": [ { "path": "registry/lib/utils.ts", "type": "registry:lib", "content": "import { clsx, type ClassValue } from \"clsx\"\r\nimport { twMerge } from \"tailwind-merge\"\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs))\r\n}\r\n", "target": "src/lib/utils.ts" } ], "dependencies": [ "clsx", "tailwind-merge" ] }, { "name": "section-variants", "type": "registry:lib", "title": "Section Variants", "description": "Shared SectionVariant type and color map for theming section backgrounds, text, cards, and icons.", "files": [ { "path": "registry/lib/section-variants.ts", "type": "registry:lib", "content": "export type SectionVariant =\r\n | \"default\"\r\n | \"muted\"\r\n | \"accent\"\r\n | \"primary\"\r\n | \"secondary\"\r\n\r\nexport const sectionVariantClasses: Record<\r\n SectionVariant,\r\n {\r\n section: string\r\n heading: string\r\n body: string\r\n eyebrow: string\r\n card: string\r\n cardBorder: string\r\n iconBadge: string\r\n iconColor: string\r\n }\r\n> = {\r\n default: {\r\n section: \"bg-background\",\r\n heading: \"text-foreground\",\r\n body: \"text-muted-foreground\",\r\n eyebrow: \"text-primary\",\r\n card: \"bg-background\",\r\n cardBorder: \"border-border\",\r\n iconBadge: \"bg-primary/10\",\r\n iconColor: \"text-primary\",\r\n },\r\n muted: {\r\n section: \"bg-muted/50\",\r\n heading: \"text-foreground\",\r\n body: \"text-muted-foreground\",\r\n eyebrow: \"text-primary\",\r\n card: \"bg-background\",\r\n cardBorder: \"border-border\",\r\n iconBadge: \"bg-primary/10\",\r\n iconColor: \"text-primary\",\r\n },\r\n accent: {\r\n section: \"bg-accent\",\r\n heading: \"text-accent-foreground\",\r\n body: \"text-accent-foreground/70\",\r\n eyebrow: \"text-primary\",\r\n card: \"bg-background\",\r\n cardBorder: \"border-border\",\r\n iconBadge: \"bg-primary/10\",\r\n iconColor: \"text-primary\",\r\n },\r\n primary: {\r\n section: \"bg-primary\",\r\n heading: \"text-primary-foreground\",\r\n body: \"text-primary-foreground/80\",\r\n eyebrow: \"text-primary-foreground/90\",\r\n card: \"bg-primary-foreground/10\",\r\n cardBorder: \"border-primary-foreground/20\",\r\n iconBadge: \"bg-primary-foreground/20\",\r\n iconColor: \"text-primary-foreground\",\r\n },\r\n secondary: {\r\n section: \"bg-secondary\",\r\n heading: \"text-secondary-foreground\",\r\n body: \"text-secondary-foreground/80\",\r\n eyebrow: \"text-secondary-foreground/90\",\r\n card: \"bg-secondary-foreground/10\",\r\n cardBorder: \"border-secondary-foreground/20\",\r\n iconBadge: \"bg-secondary-foreground/20\",\r\n iconColor: \"text-secondary-foreground\",\r\n },\r\n}\r\n", "target": "src/lib/section-variants.ts" } ] }, { "name": "i18n-engine", "type": "registry:lib", "title": "i18n Engine", "description": "Internationalization engine with getDictionary, t() dot-notation resolver, and locale middleware. Supports fr/en/de/it.", "files": [ { "path": "src/lib/i18n/config.ts", "type": "registry:lib", "content": "export const locales = [\"fr\", \"en\", \"de\", \"it\"] as const\r\nexport type Locale = (typeof locales)[number]\r\nexport const defaultLocale: Locale = \"fr\"\r\n\r\nexport const localeNames: Record = {\r\n fr: \"Français\",\r\n en: \"English\",\r\n de: \"Deutsch\",\r\n it: \"Italiano\",\r\n}\r\n\r\nexport const localeShort: Record = {\r\n fr: \"FR\",\r\n en: \"EN\",\r\n de: \"DE\",\r\n it: \"IT\",\r\n}\r\n\r\nexport const ogLocales: Record = {\r\n fr: \"fr_CH\",\r\n en: \"en_US\",\r\n de: \"de_CH\",\r\n it: \"it_CH\",\r\n}\r\n\r\nexport function isLocale(value: string): value is Locale {\r\n return (locales as readonly string[]).includes(value)\r\n}\r\n", "target": "src/lib/i18n/config.ts" }, { "path": "src/lib/i18n/index.ts", "type": "registry:lib", "content": "import \"server-only\"\r\n\r\nimport { defaultLocale, type Locale } from \"./config\"\r\n\r\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\r\nexport type Dictionary = Record\r\n\r\nconst dictionaries: Record Promise> = {\r\n fr: () => import(\"./fr.json\").then((m) => m.default),\r\n en: () => import(\"./en.json\").then((m) => m.default),\r\n de: () => import(\"./de.json\").then((m) => m.default),\r\n it: () => import(\"./it.json\").then((m) => m.default),\r\n}\r\n\r\nexport async function getDictionary(locale: Locale): Promise {\r\n const loader = dictionaries[locale] ?? dictionaries[defaultLocale]\r\n return loader()\r\n}\r\n\r\nexport function t(dictionary: Dictionary, key: string): string {\r\n const value = key\r\n .split(\".\")\r\n .reduce((acc, part) => {\r\n if (acc && typeof acc === \"object\" && part in (acc as Record)) {\r\n return (acc as Record)[part]\r\n }\r\n return undefined\r\n }, dictionary)\r\n\r\n return typeof value === \"string\" ? value : key\r\n}\r\n\r\nexport function tList(dictionary: Dictionary, key: string): string[] {\r\n const value = key.split(\".\").reduce((acc, part) => {\r\n if (acc && typeof acc === \"object\" && part in (acc as Record)) {\r\n return (acc as Record)[part]\r\n }\r\n return undefined\r\n }, dictionary)\r\n\r\n return Array.isArray(value) ? (value as string[]) : []\r\n}\r\n\r\nexport function tNode(dictionary: Dictionary, key: string): T | undefined {\r\n const value = key.split(\".\").reduce((acc, part) => {\r\n if (acc && typeof acc === \"object\" && part in (acc as Record)) {\r\n return (acc as Record)[part]\r\n }\r\n return undefined\r\n }, dictionary)\r\n\r\n return value as T | undefined\r\n}\r\n\r\nexport { type Locale, locales, defaultLocale, localeNames, localeShort, ogLocales, isLocale } from \"./config\"\r\n", "target": "src/lib/i18n/index.ts" }, { "path": "src/lib/i18n/fr.json", "type": "registry:lib", "content": "{\n \"meta\": {\n \"home\": {\n \"title\": \"Accueil | Nom du site\",\n \"description\": \"Description de la page d'accueil.\"\n },\n \"about\": {\n \"title\": \"À propos | Nom du site\",\n \"description\": \"Description de la page à propos.\"\n },\n \"services\": {\n \"title\": \"Services | Nom du site\",\n \"description\": \"Description des services.\"\n },\n \"pricing\": {\n \"title\": \"Tarifs | Nom du site\",\n \"description\": \"Consultez nos tarifs.\"\n },\n \"contact\": {\n \"title\": \"Contact | Nom du site\",\n \"description\": \"Contactez-nous.\"\n },\n \"faq\": {\n \"title\": \"FAQ | Nom du site\",\n \"description\": \"Questions fréquemment posées.\"\n },\n \"book\": {\n \"title\": \"Réserver | Nom du site\",\n \"description\": \"Prenez rendez-vous.\"\n }\n },\n \"nav\": {\n \"home\": \"Accueil\",\n \"about\": \"À propos\",\n \"services\": \"Services\",\n \"pricing\": \"Tarifs\",\n \"contact\": \"Contact\",\n \"faq\": \"FAQ\",\n \"book\": \"Réserver\"\n },\n \"common\": {\n \"readMore\": \"En savoir plus\",\n \"backToTop\": \"Retour en haut\",\n \"share\": \"Partager\",\n \"linkCopied\": \"Lien copié\",\n \"enlargeImage\": \"Cliquer pour agrandir\",\n \"close\": \"Fermer\",\n \"photoComingSoon\": \"Photo à venir\"\n },\n \"footer\": {\n \"explore\": \"Explorer\",\n \"quickLinks\": \"Liens rapides\",\n \"contact\": \"Contact\",\n \"legal\": \"Mentions légales\",\n \"privacy\": \"Confidentialité\",\n \"terms\": \"CGV\",\n \"copyright\": \"© {year} {name}. Tous droits réservés.\",\n \"madeBy\": \"Produit par The Corner Factory SA\"\n },\n \"cookies\": {\n \"text\": \"Nous utilisons des cookies pour améliorer votre expérience et analyser le trafic du site.\",\n \"acceptAll\": \"Tout accepter\",\n \"acceptSelection\": \"Accepter la sélection\",\n \"reject\": \"Refuser\",\n \"privacyLink\": \"Politique de confidentialité\",\n \"manage\": \"Gestion des cookies\",\n \"necessaryTitle\": \"Nécessaires\",\n \"necessaryDescription\": \"Requis pour le bon fonctionnement du site Web.\",\n \"analyticsTitle\": \"Statistiques\",\n \"analyticsDescription\": \"Google Analytics nous aide à comprendre comment les visiteurs interagissent avec le site (données transférées aux États-Unis).\",\n \"marketingTitle\": \"Marketing\",\n \"marketingDescription\": \"Les outils publicitaires (ex. Google Ads) mesurent les conversions et personnalisent la publicite, uniquement avec votre consentement (donnees transferees aux Etats-Unis).\"\n }\n}\n", "target": "src/lib/i18n/fr.json" }, { "path": "src/lib/i18n/en.json", "type": "registry:lib", "content": "{\n \"meta\": {\n \"home\": {\n \"title\": \"Home | Site Name\",\n \"description\": \"Home page description.\"\n },\n \"about\": {\n \"title\": \"About | Site Name\",\n \"description\": \"About page description.\"\n },\n \"services\": {\n \"title\": \"Services | Site Name\",\n \"description\": \"Services description.\"\n },\n \"pricing\": {\n \"title\": \"Pricing | Site Name\",\n \"description\": \"View our pricing.\"\n },\n \"contact\": {\n \"title\": \"Contact | Site Name\",\n \"description\": \"Get in touch.\"\n },\n \"faq\": {\n \"title\": \"FAQ | Site Name\",\n \"description\": \"Frequently asked questions.\"\n },\n \"book\": {\n \"title\": \"Book | Site Name\",\n \"description\": \"Book an appointment.\"\n }\n },\n \"nav\": {\n \"home\": \"Home\",\n \"about\": \"About\",\n \"services\": \"Services\",\n \"pricing\": \"Pricing\",\n \"contact\": \"Contact\",\n \"faq\": \"FAQ\",\n \"book\": \"Book\"\n },\n \"common\": {\n \"readMore\": \"Read more\",\n \"backToTop\": \"Back to top\",\n \"share\": \"Share\",\n \"linkCopied\": \"Link copied\",\n \"enlargeImage\": \"Click to enlarge\",\n \"close\": \"Close\",\n \"photoComingSoon\": \"Photo coming soon\"\n },\n \"footer\": {\n \"explore\": \"Explore\",\n \"quickLinks\": \"Quick Links\",\n \"contact\": \"Contact\",\n \"legal\": \"Legal Notice\",\n \"privacy\": \"Privacy Policy\",\n \"terms\": \"Terms & Conditions\",\n \"copyright\": \"© {year} {name}. All rights reserved.\",\n \"madeBy\": \"Made by The Corner Factory SA\"\n },\n \"cookies\": {\n \"text\": \"We use cookies to improve your experience and analyze site traffic.\",\n \"acceptAll\": \"Accept all\",\n \"acceptSelection\": \"Accept selection\",\n \"reject\": \"Reject\",\n \"privacyLink\": \"Privacy Policy\",\n \"manage\": \"Cookie settings\",\n \"necessaryTitle\": \"Necessary\",\n \"necessaryDescription\": \"Required for the website to function properly.\",\n \"analyticsTitle\": \"Statistics\",\n \"analyticsDescription\": \"Google Analytics helps us understand how visitors interact with the site (data transferred to the United States).\",\n \"marketingTitle\": \"Marketing\",\n \"marketingDescription\": \"Marketing tools (e.g. Google Ads) measure conversions and personalize advertising, only with your consent (data transferred to the United States).\"\n }\n}\n", "target": "src/lib/i18n/en.json" }, { "path": "src/lib/i18n/de.json", "type": "registry:lib", "content": "{\n \"meta\": {\n \"home\": {\n \"title\": \"Startseite | Webseite Name\",\n \"description\": \"Beschreibung der Startseite.\"\n },\n \"about\": {\n \"title\": \"Über uns | Webseite Name\",\n \"description\": \"Über uns Seite.\"\n },\n \"services\": {\n \"title\": \"Dienstleistungen | Webseite Name\",\n \"description\": \"Unsere Dienstleistungen.\"\n },\n \"pricing\": {\n \"title\": \"Preise | Webseite Name\",\n \"description\": \"Unsere Preise.\"\n },\n \"contact\": {\n \"title\": \"Kontakt | Webseite Name\",\n \"description\": \"Kontaktieren Sie uns.\"\n },\n \"faq\": {\n \"title\": \"FAQ | Webseite Name\",\n \"description\": \"Häufig gestellte Fragen.\"\n },\n \"book\": {\n \"title\": \"Buchen | Webseite Name\",\n \"description\": \"Termin buchen.\"\n }\n },\n \"nav\": {\n \"home\": \"Startseite\",\n \"about\": \"Über uns\",\n \"services\": \"Dienstleistungen\",\n \"pricing\": \"Preise\",\n \"contact\": \"Kontakt\",\n \"faq\": \"FAQ\",\n \"book\": \"Buchen\"\n },\n \"common\": {\n \"readMore\": \"Mehr erfahren\",\n \"backToTop\": \"Nach oben\",\n \"share\": \"Teilen\",\n \"linkCopied\": \"Link kopiert\",\n \"enlargeImage\": \"Klicken zum Vergrössern\",\n \"close\": \"Schliessen\",\n \"photoComingSoon\": \"Foto folgt\"\n },\n \"footer\": {\n \"explore\": \"Entdecken\",\n \"quickLinks\": \"Schnellzugriff\",\n \"contact\": \"Kontakt\",\n \"legal\": \"Impressum\",\n \"privacy\": \"Datenschutz\",\n \"terms\": \"AGB\",\n \"copyright\": \"© {year} {name}. Alle Rechte vorbehalten.\",\n \"madeBy\": \"Erstellt von The Corner Factory SA\"\n },\n \"cookies\": {\n \"text\": \"Wir verwenden Cookies, um Ihre Erfahrung zu verbessern und den Website-Traffic zu analysieren.\",\n \"acceptAll\": \"Alle akzeptieren\",\n \"acceptSelection\": \"Auswahl akzeptieren\",\n \"reject\": \"Ablehnen\",\n \"privacyLink\": \"Datenschutzrichtlinie\",\n \"manage\": \"Cookie-Einstellungen\",\n \"necessaryTitle\": \"Notwendig\",\n \"necessaryDescription\": \"Erforderlich für die ordnungsgemässe Funktion der Website.\",\n \"analyticsTitle\": \"Statistiken\",\n \"analyticsDescription\": \"Google Analytics hilft uns zu verstehen, wie Besucher die Website nutzen (Datenübertragung in die USA).\",\n \"marketingTitle\": \"Marketing\",\n \"marketingDescription\": \"Werbetools (z.B. Google Ads) messen Konversionen und personalisieren Werbung, nur mit Ihrer Einwilligung (Datenuebertragung in die USA).\"\n }\n}\n", "target": "src/lib/i18n/de.json" }, { "path": "src/lib/i18n/it.json", "type": "registry:lib", "content": "{\n \"meta\": {\n \"home\": {\n \"title\": \"Home | Nome del sito\",\n \"description\": \"Descrizione della pagina principale.\"\n },\n \"about\": {\n \"title\": \"Chi siamo | Nome del sito\",\n \"description\": \"Pagina chi siamo.\"\n },\n \"services\": {\n \"title\": \"Servizi | Nome del sito\",\n \"description\": \"I nostri servizi.\"\n },\n \"pricing\": {\n \"title\": \"Prezzi | Nome del sito\",\n \"description\": \"Consulta i nostri prezzi.\"\n },\n \"contact\": {\n \"title\": \"Contatto | Nome del sito\",\n \"description\": \"Contattaci.\"\n },\n \"faq\": {\n \"title\": \"FAQ | Nome del sito\",\n \"description\": \"Domande frequenti.\"\n },\n \"book\": {\n \"title\": \"Prenota | Nome del sito\",\n \"description\": \"Prenota un appuntamento.\"\n }\n },\n \"nav\": {\n \"home\": \"Home\",\n \"about\": \"Chi siamo\",\n \"services\": \"Servizi\",\n \"pricing\": \"Prezzi\",\n \"contact\": \"Contatto\",\n \"faq\": \"FAQ\",\n \"book\": \"Prenota\"\n },\n \"common\": {\n \"readMore\": \"Scopri di più\",\n \"backToTop\": \"Torna in alto\",\n \"share\": \"Condividi\",\n \"linkCopied\": \"Link copiato\",\n \"enlargeImage\": \"Clicca per ingrandire\",\n \"close\": \"Chiudi\",\n \"photoComingSoon\": \"Foto in arrivo\"\n },\n \"footer\": {\n \"explore\": \"Esplora\",\n \"quickLinks\": \"Link rapidi\",\n \"contact\": \"Contatto\",\n \"legal\": \"Note legali\",\n \"privacy\": \"Privacy\",\n \"terms\": \"Termini e condizioni\",\n \"copyright\": \"© {year} {name}. Tutti i diritti riservati.\",\n \"madeBy\": \"Realizzato da The Corner Factory SA\"\n },\n \"cookies\": {\n \"text\": \"Utilizziamo i cookie per migliorare la tua esperienza e analizzare il traffico del sito.\",\n \"acceptAll\": \"Accetta tutto\",\n \"acceptSelection\": \"Accetta selezione\",\n \"reject\": \"Rifiuta\",\n \"privacyLink\": \"Informativa sulla privacy\",\n \"manage\": \"Gestione cookie\",\n \"necessaryTitle\": \"Necessari\",\n \"necessaryDescription\": \"Necessari per il corretto funzionamento del sito.\",\n \"analyticsTitle\": \"Statistiche\",\n \"analyticsDescription\": \"Google Analytics ci aiuta a capire come i visitatori interagiscono con il sito (dati trasferiti negli Stati Uniti).\",\n \"marketingTitle\": \"Marketing\",\n \"marketingDescription\": \"Gli strumenti pubblicitari (es. Google Ads) misurano le conversioni e personalizzano la pubblicita, solo con il vostro consenso (dati trasferiti negli Stati Uniti).\"\n }\n}\n", "target": "src/lib/i18n/it.json" }, { "path": "src/middleware.ts", "type": "registry:lib", "content": "import { NextResponse, type NextRequest } from \"next/server\"\r\nimport { locales, defaultLocale } from \"@/lib/i18n/config\"\r\n\r\nfunction getLocale(request: NextRequest): string {\r\n const cookieLocale = request.cookies.get(\"NEXT_LOCALE\")?.value\r\n if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {\r\n return cookieLocale\r\n }\r\n\r\n const acceptLang = request.headers.get(\"accept-language\")\r\n if (acceptLang) {\r\n const preferred = acceptLang\r\n .split(\",\")\r\n .map((part) => part.split(\";\")[0].trim().toLowerCase().split(\"-\")[0])\r\n\r\n for (const lang of preferred) {\r\n if ((locales as readonly string[]).includes(lang)) {\r\n return lang\r\n }\r\n }\r\n }\r\n\r\n return defaultLocale\r\n}\r\n\r\nexport function middleware(request: NextRequest) {\r\n const { pathname } = request.nextUrl\r\n\r\n if (\r\n pathname.startsWith(\"/_next\") ||\r\n pathname.startsWith(\"/api\") ||\r\n pathname.includes(\".\") ||\r\n pathname === \"/robots.txt\" ||\r\n pathname === \"/sitemap.xml\"\r\n ) {\r\n return NextResponse.next()\r\n }\r\n\r\n const pathnameHasLocale = locales.some(\r\n (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),\r\n )\r\n\r\n if (pathnameHasLocale) return NextResponse.next()\r\n\r\n const locale = getLocale(request)\r\n const url = request.nextUrl.clone()\r\n url.pathname = `/${locale}${pathname === \"/\" ? \"\" : pathname}`\r\n\r\n const response = NextResponse.redirect(url)\r\n response.cookies.set(\"NEXT_LOCALE\", locale, { maxAge: 60 * 60 * 24 * 365, path: \"/\" })\r\n return response\r\n}\r\n\r\nexport const config = {\r\n matcher: [\"/((?!_next|api|.*\\\\..*).*)\"],\r\n}\r\n", "target": "src/middleware.ts" } ], "dependencies": [ "server-only", "next" ] }, { "name": "build-metadata", "type": "registry:lib", "title": "Build Metadata", "description": "SEO metadata builder with canonical, hreflang, Open Graph, and Twitter card support.", "files": [ { "path": "registry/lib/seo/build-metadata.ts", "type": "registry:lib", "content": "import type { Metadata } from \"next\"\r\nimport { locales, ogLocales, type Locale } from \"@/lib/i18n/config\"\r\n\r\ninterface BuildMetadataOptions {\r\n locale: Locale\r\n title: string\r\n description: string\r\n path: string\r\n siteUrl: string\r\n siteName: string\r\n ogImage?: string\r\n noIndex?: boolean\r\n}\r\n\r\nexport function buildMetadata({\r\n locale,\r\n title,\r\n description,\r\n path,\r\n siteUrl,\r\n siteName,\r\n ogImage,\r\n noIndex = false,\r\n}: BuildMetadataOptions): Metadata {\r\n const cleanPath = path === \"/\" ? \"\" : path\r\n const canonical = `${siteUrl}/${locale}${cleanPath}`\r\n\r\n const languages: Record = {}\r\n for (const l of locales) {\r\n languages[l] = `${siteUrl}/${l}${cleanPath}`\r\n }\r\n languages[\"x-default\"] = `${siteUrl}/${locales[0]}${cleanPath}`\r\n\r\n return {\r\n title,\r\n description,\r\n alternates: { canonical, languages },\r\n robots: noIndex ? { index: false, follow: true } : undefined,\r\n openGraph: {\r\n title,\r\n description,\r\n url: canonical,\r\n siteName,\r\n locale: ogLocales[locale],\r\n type: \"website\",\r\n images: ogImage\r\n ? [{ url: ogImage, width: 1200, height: 630, alt: siteName }]\r\n : undefined,\r\n },\r\n twitter: {\r\n card: \"summary_large_image\",\r\n title,\r\n description,\r\n images: ogImage ? [ogImage] : undefined,\r\n },\r\n }\r\n}\r\n", "target": "src/lib/seo/build-metadata.ts" } ], "dependencies": [ "next" ], "registryDependencies": [ "i18n-engine" ] }, { "name": "json-ld", "type": "registry:lib", "title": "JSON-LD Schemas", "description": "Structured data helpers for Organization, Breadcrumb, FAQ, and Service schemas.", "files": [ { "path": "registry/lib/seo/json-ld.tsx", "type": "registry:lib", "content": "interface JsonLdProps {\r\n data: Record\r\n}\r\n\r\nexport function JsonLd({ data }: JsonLdProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nexport function organizationJsonLd({\r\n name,\r\n url,\r\n logo,\r\n phone,\r\n email,\r\n address,\r\n}: {\r\n name: string\r\n url: string\r\n logo?: string\r\n phone?: string\r\n email?: string\r\n address?: { street: string; city: string; postalCode: string; country: string }\r\n}) {\r\n return {\r\n \"@context\": \"https://schema.org\",\r\n \"@type\": \"LocalBusiness\",\r\n \"@id\": `${url}/#organization`,\r\n name,\r\n url,\r\n ...(logo && { logo }),\r\n ...(phone && { telephone: phone }),\r\n ...(email && { email }),\r\n ...(address && {\r\n address: {\r\n \"@type\": \"PostalAddress\",\r\n streetAddress: address.street,\r\n addressLocality: address.city,\r\n postalCode: address.postalCode,\r\n addressCountry: address.country,\r\n },\r\n }),\r\n }\r\n}\r\n\r\nexport function breadcrumbJsonLd(items: { name: string; url: string }[]) {\r\n return {\r\n \"@context\": \"https://schema.org\",\r\n \"@type\": \"BreadcrumbList\",\r\n itemListElement: items.map((item, index) => ({\r\n \"@type\": \"ListItem\",\r\n position: index + 1,\r\n name: item.name,\r\n item: item.url,\r\n })),\r\n }\r\n}\r\n\r\nexport function faqJsonLd(items: { question: string; answer: string }[]) {\r\n return {\r\n \"@context\": \"https://schema.org\",\r\n \"@type\": \"FAQPage\",\r\n mainEntity: items.map((item) => ({\r\n \"@type\": \"Question\",\r\n name: item.question,\r\n acceptedAnswer: { \"@type\": \"Answer\", text: item.answer },\r\n })),\r\n }\r\n}\r\n\r\nexport function serviceJsonLd({\r\n name,\r\n description,\r\n provider,\r\n url,\r\n}: {\r\n name: string\r\n description: string\r\n provider: { name: string; url: string }\r\n url: string\r\n}) {\r\n return {\r\n \"@context\": \"https://schema.org\",\r\n \"@type\": \"Service\",\r\n name,\r\n description,\r\n url,\r\n provider: {\r\n \"@type\": \"LocalBusiness\",\r\n name: provider.name,\r\n url: provider.url,\r\n },\r\n }\r\n}\r\n", "target": "src/lib/seo/json-ld.tsx" } ] }, { "name": "social-icons", "type": "registry:ui", "title": "Social Icons", "description": "Inline SVG icons for Instagram, Facebook, LinkedIn, and YouTube.", "files": [ { "path": "registry/components/ui/social-icons.tsx", "type": "registry:component", "content": "import type { SVGProps } from \"react\"\r\n\r\nexport type SocialPlatform =\r\n | \"instagram\"\r\n | \"facebook\"\r\n | \"linkedin\"\r\n | \"youtube\"\r\n | \"x\"\r\n | \"tiktok\"\r\n | \"whatsapp\"\r\n\r\ntype IconProps = SVGProps\r\n\r\nexport type { IconProps }\r\n\r\nexport const socialIconMap: Record>> = {\r\n instagram: InstagramIcon,\r\n facebook: FacebookIcon,\r\n linkedin: LinkedinIcon,\r\n youtube: YoutubeIcon,\r\n x: XIcon,\r\n tiktok: TiktokIcon,\r\n whatsapp: WhatsappIcon,\r\n}\r\n\r\nexport function InstagramIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport function FacebookIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport function LinkedinIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport function YoutubeIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport function XIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport function TiktokIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nexport function WhatsappIcon(props: IconProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n", "target": "src/components/ui/social-icons.tsx" } ] }, { "name": "animations", "type": "registry:ui", "title": "Animations", "description": "Scroll-triggered animation wrappers: FadeUp, FadeIn, ScaleIn, StaggerContainer, HeroAnimation, ImageReveal.", "files": [ { "path": "registry/components/ui/animations.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { motion, type Variants } from \"motion/react\"\r\nimport type { ReactNode } from \"react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nconst fadeUp: Variants = {\r\n hidden: { opacity: 0, y: 20 },\r\n visible: { opacity: 1, y: 0 },\r\n}\r\n\r\nconst fadeIn: Variants = {\r\n hidden: { opacity: 0 },\r\n visible: { opacity: 1 },\r\n}\r\n\r\nconst scaleIn: Variants = {\r\n hidden: { opacity: 0, scale: 0.95 },\r\n visible: { opacity: 1, scale: 1 },\r\n}\r\n\r\nexport interface AnimationProps {\r\n children: ReactNode\r\n delay?: number\r\n className?: string\r\n}\r\n\r\nexport function FadeUp({ children, delay = 0, className }: AnimationProps) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function FadeIn({ children, delay = 0, className }: AnimationProps) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function ScaleIn({ children, delay = 0, className }: AnimationProps) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function StaggerContainer({\r\n children,\r\n className,\r\n staggerDelay = 0.1,\r\n}: {\r\n children: ReactNode\r\n className?: string\r\n staggerDelay?: number\r\n}) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function StaggerItem({ children, className }: { children: ReactNode; className?: string }) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function HeroAnimation({ children, className }: { children: ReactNode; className?: string }) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function ImageReveal({ children, delay = 0, className }: AnimationProps) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/animations.tsx" } ], "dependencies": [ "motion" ] }, { "name": "reveal", "type": "registry:ui", "title": "Reveal", "description": "Lightweight scroll-reveal wrapper with configurable fade-up animation.", "files": [ { "path": "registry/components/ui/reveal.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { motion } from \"motion/react\"\r\nimport type { ReactNode } from \"react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface RevealProps {\r\n children: ReactNode\r\n delay?: number\r\n y?: number\r\n className?: string\r\n}\r\n\r\nexport function Reveal({\r\n children,\r\n delay = 0,\r\n y = 24,\r\n className,\r\n}: RevealProps) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/reveal.tsx" } ], "dependencies": [ "motion" ] }, { "name": "share-button", "type": "registry:ui", "title": "Share Button", "description": "Web Share API button with clipboard fallback.", "files": [ { "path": "registry/components/ui/share-button.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useState, useRef } from \"react\"\r\nimport { Share2, Copy, Check } from \"lucide-react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface ShareButtonProps {\r\n title: string\r\n text: string\r\n shareLabel?: string\r\n copiedLabel?: string\r\n url?: string\r\n className?: string\r\n}\r\n\r\nexport function ShareButton({\r\n title,\r\n text,\r\n shareLabel = \"Share\",\r\n copiedLabel = \"Link copied\",\r\n url,\r\n className,\r\n}: ShareButtonProps) {\r\n const [copied, setCopied] = useState(false)\r\n const sharing = useRef(false)\r\n\r\n const handleShare = async () => {\r\n if (sharing.current) return\r\n sharing.current = true\r\n\r\n const shareUrl = url ?? window.location.href\r\n\r\n try {\r\n if (navigator.share) {\r\n await navigator.share({ title, text, url: shareUrl })\r\n } else {\r\n await navigator.clipboard.writeText(`${text} : ${shareUrl}`)\r\n setCopied(true)\r\n setTimeout(() => setCopied(false), 2000)\r\n }\r\n } catch {\r\n /* user cancelled — ignore */\r\n } finally {\r\n sharing.current = false\r\n }\r\n }\r\n\r\n return (\r\n \r\n {copied ? (\r\n <>\r\n \r\n {copiedLabel}\r\n \r\n ) : (\r\n <>\r\n \r\n {shareLabel}\r\n \r\n )}\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/share-button.tsx" } ], "dependencies": [ "lucide-react" ] }, { "name": "back-to-top", "type": "registry:ui", "title": "Back to Top", "description": "Scroll-to-top floating button that appears after scrolling.", "files": [ { "path": "registry/components/ui/back-to-top.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useEffect, useState } from \"react\"\r\nimport { ArrowUp } from \"lucide-react\"\r\n\r\nexport interface BackToTopProps {\r\n ariaLabel?: string\r\n}\r\n\r\nexport function BackToTop({ ariaLabel = \"Back to top\" }: BackToTopProps) {\r\n const [show, setShow] = useState(false)\r\n\r\n useEffect(() => {\r\n const onScroll = () => setShow(window.scrollY > 400)\r\n onScroll()\r\n window.addEventListener(\"scroll\", onScroll, { passive: true })\r\n return () => window.removeEventListener(\"scroll\", onScroll)\r\n }, [])\r\n\r\n if (!show) return null\r\n\r\n return (\r\n window.scrollTo({ top: 0, behavior: \"smooth\" })}\r\n className=\"fixed bottom-6 right-6 z-50 flex h-11 w-11 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-all hover:bg-primary/90 hover:scale-105 focus-visible:ring-3 focus-visible:ring-ring/40\"\r\n aria-label={ariaLabel}\r\n >\r\n \r\n \r\n )\r\n}\r\n", "target": "src/components/ui/back-to-top.tsx" } ], "dependencies": [ "lucide-react" ] }, { "name": "section-heading", "type": "registry:ui", "title": "Section Heading", "description": "Reusable section heading with eyebrow label, title, subtitle, alignment, and color variant support.", "files": [ { "path": "registry/components/ui/section-heading.tsx", "type": "registry:component", "content": "import {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface SectionHeadingProps {\r\n eyebrow?: string\r\n title: string\r\n subtitle?: string\r\n align?: \"center\" | \"left\"\r\n as?: \"h1\" | \"h2\" | \"h3\"\r\n variant?: SectionVariant\r\n /** @deprecated Use variant=\"primary\" instead */\r\n inverted?: boolean\r\n className?: string\r\n}\r\n\r\nexport function SectionHeading({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n align = \"center\",\r\n as: Tag = \"h2\",\r\n variant: variantProp,\r\n inverted = false,\r\n className,\r\n}: SectionHeadingProps) {\r\n const variant = variantProp ?? (inverted ? \"primary\" : \"default\")\r\n const colors = sectionVariantClasses[variant]\r\n const isDark = variant === \"primary\" || variant === \"secondary\"\r\n\r\n return (\r\n \r\n {eyebrow ? (\r\n <>\r\n \r\n {eyebrow}\r\n \r\n \r\n \r\n ) : null}\r\n \r\n {title}\r\n \r\n {subtitle ? (\r\n \r\n {subtitle}\r\n

\r\n ) : null}\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/section-heading.tsx" } ], "registryDependencies": [ "cn", "section-variants" ] }, { "name": "image-with-fallback", "type": "registry:ui", "title": "Image with Fallback", "description": "next/image wrapper with error fallback placeholder, quality control, and accessibility.", "files": [ { "path": "registry/components/ui/image-with-fallback.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useState } from \"react\"\r\nimport Image from \"next/image\"\r\nimport { ImageIcon } from \"lucide-react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface ImageWithFallbackProps {\r\n src: string\r\n alt: string\r\n width?: number\r\n height?: number\r\n fill?: boolean\r\n priority?: boolean\r\n sizes?: string\r\n quality?: number\r\n className?: string\r\n fallbackLabel?: string\r\n}\r\n\r\nexport function ImageWithFallback({\r\n src,\r\n alt,\r\n width,\r\n height,\r\n fill = false,\r\n priority = false,\r\n sizes,\r\n quality,\r\n className,\r\n fallbackLabel = \"Photo coming soon\",\r\n}: ImageWithFallbackProps) {\r\n const [errored, setErrored] = useState(false)\r\n\r\n if (errored || !src) {\r\n return (\r\n \r\n \r\n {fallbackLabel}\r\n \r\n )\r\n }\r\n\r\n return (\r\n setErrored(true)}\r\n className={cn(fill ? \"object-cover\" : \"\", className)}\r\n />\r\n )\r\n}\r\n", "target": "src/components/ui/image-with-fallback.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn" ] }, { "name": "lightbox", "type": "registry:ui", "title": "Lightbox", "description": "Click-to-enlarge image overlay with Escape and click-outside dismiss.", "files": [ { "path": "registry/components/ui/lightbox.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useState, useCallback, useEffect } from \"react\"\r\nimport Image from \"next/image\"\r\nimport { X } from \"lucide-react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface LightboxProps {\r\n src: string\r\n alt: string\r\n width?: number\r\n height?: number\r\n fill?: boolean\r\n sizes?: string\r\n className?: string\r\n enlargeLabel?: string\r\n closeLabel?: string\r\n children?: React.ReactNode\r\n}\r\n\r\nexport function Lightbox({\r\n src,\r\n alt,\r\n width,\r\n height,\r\n fill,\r\n sizes,\r\n className,\r\n enlargeLabel = \"Click to enlarge\",\r\n closeLabel = \"Close\",\r\n children,\r\n}: LightboxProps) {\r\n const [open, setOpen] = useState(false)\r\n const openModal = useCallback(() => setOpen(true), [])\r\n const closeModal = useCallback(() => setOpen(false), [])\r\n\r\n useEffect(() => {\r\n if (!open) return\r\n const onKey = (e: KeyboardEvent) => {\r\n if (e.key === \"Escape\") closeModal()\r\n }\r\n document.addEventListener(\"keydown\", onKey)\r\n document.body.style.overflow = \"hidden\"\r\n return () => {\r\n document.removeEventListener(\"keydown\", onKey)\r\n document.body.style.overflow = \"\"\r\n }\r\n }, [open, closeModal])\r\n\r\n return (\r\n <>\r\n \r\n {children ?? (\r\n \r\n )}\r\n \r\n \r\n {enlargeLabel}\r\n \r\n \r\n \r\n\r\n {open && (\r\n \r\n \r\n \r\n \r\n e.stopPropagation()}\r\n />\r\n \r\n )}\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/lightbox.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn" ] }, { "name": "cta-button", "type": "registry:ui", "title": "CTA Button", "description": "Call-to-action button with CtaLink (internal) and CtaExternal (new tab) variants in 4 styles.", "files": [ { "path": "registry/components/ui/cta-button.tsx", "type": "registry:component", "content": "import Link from \"next/link\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport type CtaVariant = \"primary\" | \"secondary\" | \"outline\" | \"onDark\"\r\nexport type CtaSize = \"default\" | \"lg\"\r\n\r\nconst variantClasses: Record = {\r\n primary:\r\n \"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/40\",\r\n secondary:\r\n \"bg-secondary text-secondary-foreground hover:bg-secondary/90 focus-visible:ring-secondary/40\",\r\n outline:\r\n \"border border-border bg-background text-foreground hover:bg-muted focus-visible:ring-ring/40\",\r\n onDark:\r\n \"bg-background text-foreground hover:bg-background/90 focus-visible:ring-background/50\",\r\n}\r\n\r\nconst sizeClasses: Record = {\r\n default: \"h-11 px-5 text-sm\",\r\n lg: \"h-12 px-7 text-base\",\r\n}\r\n\r\nconst base =\r\n \"inline-flex items-center justify-center gap-2 rounded-lg font-semibold whitespace-nowrap transition-colors outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-5 [&_svg]:shrink-0\"\r\n\r\nexport interface CtaButtonProps {\r\n children: React.ReactNode\r\n variant?: CtaVariant\r\n size?: CtaSize\r\n className?: string\r\n}\r\n\r\nexport function CtaLink({\r\n href,\r\n children,\r\n variant = \"primary\",\r\n size = \"default\",\r\n className,\r\n}: CtaButtonProps & { href: string }) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nexport function CtaExternal({\r\n href,\r\n children,\r\n variant = \"primary\",\r\n size = \"default\",\r\n className,\r\n}: CtaButtonProps & { href: string }) {\r\n return (\r\n \r\n {children}\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/cta-button.tsx" } ], "dependencies": [ "next" ], "registryDependencies": [ "cn" ] }, { "name": "breadcrumb", "type": "registry:ui", "title": "Breadcrumb", "description": "Semantic breadcrumb navigation with accessible markup.", "files": [ { "path": "registry/components/ui/breadcrumb.tsx", "type": "registry:component", "content": "import Link from \"next/link\"\r\nimport { ChevronRight } from \"lucide-react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface Crumb {\r\n label: string\r\n href: string\r\n}\r\n\r\nexport interface BreadcrumbProps {\r\n homeLabel: string\r\n items: Crumb[]\r\n className?: string\r\n}\r\n\r\nexport function Breadcrumb({ homeLabel, items, className }: BreadcrumbProps) {\r\n const all: Crumb[] = [{ label: homeLabel, href: \"/\" }, ...items]\r\n\r\n return (\r\n \r\n )\r\n}\r\n", "target": "src/components/ui/breadcrumb.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn" ] }, { "name": "dropdown-menu", "type": "registry:ui", "title": "Dropdown Menu", "description": "Radix-based dropdown menu with support for items, checkboxes, radio groups, and submenus.", "files": [ { "path": "registry/components/ui/dropdown-menu.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport * as React from \"react\"\r\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\r\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nfunction DropdownMenu({\r\n ...props\r\n}: React.ComponentProps) {\r\n return \r\n}\r\n\r\nfunction DropdownMenuPortal({\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuTrigger({\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuContent({\r\n className,\r\n sideOffset = 4,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n \r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuGroup({\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuItem({\r\n className,\r\n inset,\r\n variant = \"default\",\r\n ...props\r\n}: React.ComponentProps & {\r\n inset?: boolean\r\n variant?: \"default\" | \"destructive\"\r\n}) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuCheckboxItem({\r\n className,\r\n children,\r\n checked,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n \r\n \r\n \r\n \r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuRadioGroup({\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuRadioItem({\r\n className,\r\n children,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n \r\n \r\n \r\n \r\n \r\n {children}\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuLabel({\r\n className,\r\n inset,\r\n ...props\r\n}: React.ComponentProps & {\r\n inset?: boolean\r\n}) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuSeparator({\r\n className,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuShortcut({\r\n className,\r\n ...props\r\n}: React.ComponentProps<\"span\">) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuSub({\r\n ...props\r\n}: React.ComponentProps) {\r\n return \r\n}\r\n\r\nfunction DropdownMenuSubTrigger({\r\n className,\r\n inset,\r\n children,\r\n ...props\r\n}: React.ComponentProps & {\r\n inset?: boolean\r\n}) {\r\n return (\r\n \r\n {children}\r\n \r\n \r\n )\r\n}\r\n\r\nfunction DropdownMenuSubContent({\r\n className,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nexport {\r\n DropdownMenu,\r\n DropdownMenuPortal,\r\n DropdownMenuTrigger,\r\n DropdownMenuContent,\r\n DropdownMenuGroup,\r\n DropdownMenuLabel,\r\n DropdownMenuItem,\r\n DropdownMenuCheckboxItem,\r\n DropdownMenuRadioGroup,\r\n DropdownMenuRadioItem,\r\n DropdownMenuSeparator,\r\n DropdownMenuShortcut,\r\n DropdownMenuSub,\r\n DropdownMenuSubTrigger,\r\n DropdownMenuSubContent,\r\n}\r\n", "target": "src/components/ui/dropdown-menu.tsx" } ], "dependencies": [ "lucide-react", "radix-ui" ], "registryDependencies": [ "cn" ] }, { "name": "sheet", "type": "registry:ui", "title": "Sheet", "description": "Slide-out panel (drawer) built on Radix Dialog with top/right/bottom/left variants.", "files": [ { "path": "registry/components/ui/sheet.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport * as React from \"react\"\r\nimport { XIcon } from \"lucide-react\"\r\nimport { Dialog as SheetPrimitive } from \"radix-ui\"\r\n\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nfunction Sheet({ ...props }: React.ComponentProps) {\r\n return \r\n}\r\n\r\nfunction SheetTrigger({\r\n ...props\r\n}: React.ComponentProps) {\r\n return \r\n}\r\n\r\nfunction SheetClose({\r\n ...props\r\n}: React.ComponentProps) {\r\n return \r\n}\r\n\r\nfunction SheetPortal({\r\n ...props\r\n}: React.ComponentProps) {\r\n return \r\n}\r\n\r\nfunction SheetOverlay({\r\n className,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction SheetContent({\r\n className,\r\n children,\r\n side = \"right\",\r\n showCloseButton = true,\r\n closeLabel = \"Close\",\r\n ...props\r\n}: React.ComponentProps & {\r\n side?: \"top\" | \"right\" | \"bottom\" | \"left\"\r\n showCloseButton?: boolean\r\n closeLabel?: string\r\n}) {\r\n return (\r\n \r\n \r\n \r\n {children}\r\n {showCloseButton && (\r\n \r\n \r\n {closeLabel}\r\n \r\n )}\r\n \r\n \r\n )\r\n}\r\n\r\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction SheetFooter({ className, ...props }: React.ComponentProps<\"div\">) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction SheetTitle({\r\n className,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nfunction SheetDescription({\r\n className,\r\n ...props\r\n}: React.ComponentProps) {\r\n return (\r\n \r\n )\r\n}\r\n\r\nexport {\r\n Sheet,\r\n SheetTrigger,\r\n SheetClose,\r\n SheetContent,\r\n SheetHeader,\r\n SheetFooter,\r\n SheetTitle,\r\n SheetDescription,\r\n}\r\n", "target": "src/components/ui/sheet.tsx" } ], "dependencies": [ "lucide-react", "radix-ui" ], "registryDependencies": [ "cn" ] }, { "name": "newsletter", "type": "registry:ui", "title": "Newsletter", "description": "Reusable newsletter signup form with accessible states and async submit support.", "files": [ { "path": "registry/components/forms/newsletter.tsx", "type": "registry:component", "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\n\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nexport type NewsletterProps = Omit, \"onSubmit\"> & {\r\n title?: string;\r\n description?: string;\r\n eyebrow?: string;\r\n placeholder?: string;\r\n buttonLabel?: string;\r\n privacyText?: string;\r\n loading?: boolean;\r\n successMessage?: string;\r\n errorMessage?: string;\r\n disabled?: boolean;\r\n icon?: React.ReactNode;\r\n validationErrorMessage?: string;\r\n submitErrorMessage?: string;\r\n emailLabel?: string;\r\n loadingLabel?: string;\r\n onSubmit: (email: string) => void | Promise;\r\n};\r\n\r\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\r\n\r\nexport function Newsletter({\r\n title = \"Stay in the loop\",\r\n description = \"Get product updates and practical tips delivered to your inbox.\",\r\n eyebrow,\r\n placeholder = \"you@example.com\",\r\n buttonLabel = \"Subscribe\",\r\n privacyText,\r\n loading = false,\r\n successMessage,\r\n errorMessage,\r\n disabled = false,\r\n icon,\r\n validationErrorMessage = \"Please enter a valid email address.\",\r\n submitErrorMessage = \"Something went wrong. Please try again.\",\r\n emailLabel = \"Email address\",\r\n loadingLabel = \"Submitting...\",\r\n onSubmit,\r\n className,\r\n ...props\r\n}: NewsletterProps) {\r\n const baseId = React.useId();\r\n const inputId = `${baseId}-email`;\r\n const helperId = `${baseId}-helper`;\r\n const errorId = `${baseId}-error`;\r\n const successId = `${baseId}-success`;\r\n\r\n const [email, setEmail] = React.useState(\"\");\r\n const [validationError, setValidationError] = React.useState();\r\n const [submitError, setSubmitError] = React.useState();\r\n const [showStatus, setShowStatus] = React.useState(true);\r\n const [submitting, setSubmitting] = React.useState(false);\r\n\r\n const hasError = Boolean(validationError || submitError || (showStatus && errorMessage));\r\n const inlineError = validationError || submitError || (showStatus ? errorMessage : undefined);\r\n const inlineSuccess = !inlineError && showStatus ? successMessage : undefined;\r\n const isLoading = loading || submitting;\r\n const isDisabled = disabled || isLoading;\r\n\r\n const describedBy = [privacyText ? helperId : undefined, inlineError ? errorId : undefined]\r\n .filter(Boolean)\r\n .join(\" \");\r\n\r\n async function handleSubmit(event: React.FormEvent) {\r\n event.preventDefault();\r\n\r\n if (isDisabled) {\r\n return;\r\n }\r\n\r\n const normalizedEmail = email.trim();\r\n\r\n if (!EMAIL_REGEX.test(normalizedEmail)) {\r\n setShowStatus(false);\r\n setValidationError(validationErrorMessage);\r\n return;\r\n }\r\n\r\n setShowStatus(true);\r\n setValidationError(undefined);\r\n setSubmitError(undefined);\r\n\r\n try {\r\n setSubmitting(true);\r\n await Promise.resolve(onSubmit(normalizedEmail));\r\n } catch {\r\n setSubmitError(submitErrorMessage);\r\n } finally {\r\n setSubmitting(false);\r\n }\r\n }\r\n\r\n return (\r\n \r\n
\r\n {eyebrow ? (\r\n

\r\n {eyebrow}\r\n

\r\n ) : null}\r\n
\r\n {icon ? {icon} : null}\r\n

{title}

\r\n
\r\n

{description}

\r\n
\r\n\r\n
\r\n \r\n
\r\n {\r\n setEmail(event.target.value);\r\n setValidationError(undefined);\r\n setSubmitError(undefined);\r\n setShowStatus(false);\r\n }}\r\n placeholder={placeholder}\r\n disabled={isDisabled}\r\n aria-invalid={hasError}\r\n aria-describedby={describedBy || undefined}\r\n className=\"h-11 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60\"\r\n />\r\n \r\n {isLoading ? loadingLabel : buttonLabel}\r\n \r\n
\r\n\r\n {privacyText ? (\r\n

\r\n {privacyText}\r\n

\r\n ) : null}\r\n\r\n {inlineError ? (\r\n

\r\n {inlineError}\r\n

\r\n ) : null}\r\n\r\n {inlineSuccess ? (\r\n

\r\n {inlineSuccess}\r\n

\r\n ) : null}\r\n
\r\n \r\n );\r\n}\r\n", "target": "src/components/forms/newsletter.tsx" }, { "path": "registry/components/forms/newsletter-example.tsx", "type": "registry:example", "content": "\"use client\";\r\n\r\nimport * as React from \"react\";\r\n\r\nimport { Newsletter } from \"@/components/forms/newsletter\";\r\n\r\nexport function NewsletterExample() {\r\n const [loading, setLoading] = React.useState(false);\r\n const [successMessage, setSuccessMessage] = React.useState();\r\n const [errorMessage, setErrorMessage] = React.useState();\r\n\r\n async function handleSubmit(email: string) {\r\n setLoading(true);\r\n setSuccessMessage(undefined);\r\n setErrorMessage(undefined);\r\n\r\n try {\r\n await new Promise((resolve) => setTimeout(resolve, 600));\r\n setSuccessMessage(`Thanks for subscribing, ${email}!`);\r\n } catch {\r\n setErrorMessage(\"Could not subscribe right now. Please try again.\");\r\n } finally {\r\n setLoading(false);\r\n }\r\n }\r\n\r\n return (\r\n \r\n );\r\n}\r\n", "target": "src/components/forms/newsletter-example.tsx" } ], "registryDependencies": [ "cn" ] }, { "name": "cookie-banner", "type": "registry:block", "title": "Cookie Banner", "description": "GA4 consent mode cookie banner with localStorage persistence and accept/decline.", "files": [ { "path": "registry/components/layouts/cookie-banner.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useEffect, useState, useCallback } from \"react\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\ndeclare global {\r\n interface Window {\r\n gtag?: (...args: unknown[]) => void\r\n dataLayer?: Object[]\r\n }\r\n}\r\n\r\nexport interface ConsentState {\r\n necessary: boolean\r\n analytics: boolean\r\n marketing: boolean\r\n}\r\n\r\nconst DEFAULT_CONSENT: ConsentState = {\r\n necessary: true,\r\n analytics: false,\r\n marketing: false,\r\n}\r\n\r\nfunction ensureGtag() {\r\n if (typeof window === \"undefined\") return\r\n window.gtag =\r\n window.gtag ||\r\n ((...args: unknown[]) => {\r\n window.dataLayer = window.dataLayer || []\r\n window.dataLayer.push(args)\r\n })\r\n}\r\n\r\nfunction serialize(state: ConsentState): string {\r\n return JSON.stringify(state)\r\n}\r\n\r\nfunction deserialize(raw: string): ConsentState | null {\r\n try {\r\n const parsed = JSON.parse(raw)\r\n if (\r\n typeof parsed === \"object\" &&\r\n parsed !== null &&\r\n typeof parsed.necessary === \"boolean\" &&\r\n typeof parsed.analytics === \"boolean\" &&\r\n typeof parsed.marketing === \"boolean\"\r\n ) {\r\n return parsed as ConsentState\r\n }\r\n return null\r\n } catch {\r\n return null\r\n }\r\n}\r\n\r\nfunction applyConsent(state: ConsentState) {\r\n ensureGtag()\r\n window.gtag?.(\"consent\", \"update\", {\r\n ad_storage: state.marketing ? \"granted\" : \"denied\",\r\n analytics_storage: state.analytics ? \"granted\" : \"denied\",\r\n ad_user_data: state.marketing ? \"granted\" : \"denied\",\r\n ad_personalization: state.marketing ? \"granted\" : \"denied\",\r\n })\r\n}\r\n\r\nexport interface CookieBannerProps {\r\n consentKey?: string\r\n manageEvent?: string\r\n text: string\r\n acceptAllLabel: string\r\n acceptSelectionLabel: string\r\n rejectLabel: string\r\n policyLabel: string\r\n privacyHref: string\r\n necessaryTitle: string\r\n necessaryDescription: string\r\n analyticsTitle: string\r\n analyticsDescription: string\r\n marketingTitle: string\r\n marketingDescription: string\r\n dialogLabel?: string\r\n showAnalytics?: boolean\r\n showMarketing?: boolean\r\n hidden?: boolean\r\n}\r\n\r\nexport function CookieBanner({\r\n consentKey = \"cookie-consent\",\r\n manageEvent = \"manage-cookies\",\r\n text,\r\n acceptAllLabel,\r\n acceptSelectionLabel,\r\n rejectLabel,\r\n policyLabel,\r\n privacyHref,\r\n necessaryTitle,\r\n necessaryDescription,\r\n analyticsTitle,\r\n analyticsDescription,\r\n marketingTitle,\r\n marketingDescription,\r\n dialogLabel = \"Cookies\",\r\n showAnalytics = true,\r\n showMarketing = false,\r\n hidden = false,\r\n}: CookieBannerProps) {\r\n const [visible, setVisible] = useState(false)\r\n const [draft, setDraft] = useState(DEFAULT_CONSENT)\r\n\r\n const evaluate = useCallback(() => {\r\n const stored = localStorage.getItem(consentKey)\r\n if (stored) {\r\n const parsed = deserialize(stored)\r\n if (parsed) {\r\n applyConsent(parsed)\r\n setDraft(parsed)\r\n setVisible(false)\r\n return\r\n }\r\n }\r\n applyConsent(DEFAULT_CONSENT)\r\n setDraft(DEFAULT_CONSENT)\r\n setVisible(true)\r\n }, [consentKey])\r\n\r\n useEffect(() => {\r\n evaluate()\r\n const onStorage = (e: StorageEvent) => {\r\n if (e.key === consentKey) evaluate()\r\n }\r\n const onManage = () => {\r\n const stored = localStorage.getItem(consentKey)\r\n setDraft(stored ? (deserialize(stored) ?? DEFAULT_CONSENT) : DEFAULT_CONSENT)\r\n setVisible(true)\r\n }\r\n window.addEventListener(\"storage\", onStorage)\r\n window.addEventListener(manageEvent, onManage)\r\n return () => {\r\n window.removeEventListener(\"storage\", onStorage)\r\n window.removeEventListener(manageEvent, onManage)\r\n }\r\n }, [evaluate, consentKey, manageEvent])\r\n\r\n const acceptAll = () => {\r\n const state: ConsentState = { necessary: true, analytics: true, marketing: true }\r\n localStorage.setItem(consentKey, serialize(state))\r\n applyConsent(state)\r\n setVisible(false)\r\n }\r\n\r\n const rejectAll = () => {\r\n const state: ConsentState = { necessary: true, analytics: false, marketing: false }\r\n localStorage.setItem(consentKey, serialize(state))\r\n applyConsent(state)\r\n setVisible(false)\r\n }\r\n\r\n const acceptSelection = () => {\r\n localStorage.setItem(consentKey, serialize(draft))\r\n applyConsent(draft)\r\n setVisible(false)\r\n }\r\n\r\n if (hidden || !visible) return null\r\n\r\n return (\r\n \r\n
\r\n
\r\n

\r\n {text}{\" \"}\r\n \r\n {policyLabel}\r\n \r\n

\r\n\r\n {(showAnalytics || showMarketing) ? (\r\n
\r\n \r\n\r\n {showAnalytics && (\r\n \r\n )}\r\n\r\n {showMarketing && (\r\n \r\n )}\r\n
\r\n ) : null}\r\n\r\n
\r\n \r\n {acceptAllLabel}\r\n \r\n {(showAnalytics || showMarketing) && (\r\n \r\n {acceptSelectionLabel}\r\n \r\n )}\r\n \r\n {rejectLabel}\r\n \r\n
\r\n
\r\n
\r\n \r\n )\r\n}\r\n", "target": "src/components/layouts/cookie-banner.tsx" } ], "registryDependencies": [ "cn" ] }, { "name": "consent-init", "type": "registry:block", "title": "Consent Init", "description": "Next.js Script component that sets GA4 consent defaults (denied) before hydration.", "files": [ { "path": "registry/components/layouts/consent-init.tsx", "type": "registry:component", "content": "import Script from \"next/script\"\r\n\r\nexport function ConsentInit() {\r\n return (\r\n \r\n )\r\n}\r\n", "target": "src/components/layouts/consent-init.tsx" } ], "dependencies": [ "next" ] }, { "name": "language-switcher", "type": "registry:ui", "title": "Language Switcher", "description": "Locale dropdown switcher for i18n navigation.", "files": [ { "path": "registry/components/navigation/language-switcher.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport Link from \"next/link\"\r\nimport { usePathname } from \"next/navigation\"\r\nimport { Globe, Check } from \"lucide-react\"\r\nimport {\r\n DropdownMenu,\r\n DropdownMenuContent,\r\n DropdownMenuItem,\r\n DropdownMenuTrigger,\r\n} from \"@/components/ui/dropdown-menu\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface LanguageSwitcherProps {\r\n locale: string\r\n locales: string[]\r\n localeNames: Record\r\n localeShort: Record\r\n ariaLabel?: string\r\n className?: string\r\n}\r\n\r\nexport function LanguageSwitcher({\r\n locale,\r\n locales,\r\n localeNames,\r\n localeShort,\r\n ariaLabel = \"Language\",\r\n className,\r\n}: LanguageSwitcherProps) {\r\n const pathname = usePathname()\r\n const pathnameWithoutLocale =\r\n pathname.replace(new RegExp(`^/(${locales.join(\"|\")})`), \"\") || \"\"\r\n\r\n return (\r\n \r\n \r\n \r\n {localeShort[locale] ?? locale.toUpperCase()}\r\n \r\n \r\n {locales.map((l) => (\r\n \r\n \r\n {localeNames[l] ?? l}\r\n {l === locale && (\r\n \r\n )}\r\n \r\n \r\n ))}\r\n \r\n \r\n )\r\n}\r\n", "target": "src/components/navigation/language-switcher.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn", "dropdown-menu" ] }, { "name": "navbar", "type": "registry:block", "title": "Navbar", "description": "Responsive navbar with dropdown submenus, mobile sheet menu, language switcher, and CTA button.", "files": [ { "path": "registry/components/navigation/navbar.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useState, useEffect } from \"react\"\r\nimport Link from \"next/link\"\r\nimport { usePathname } from \"next/navigation\"\r\nimport { Menu, X, ChevronDown } from \"lucide-react\"\r\nimport { Sheet, SheetContent, SheetTrigger, SheetTitle } from \"@/components/ui/sheet\"\r\nimport {\r\n DropdownMenu,\r\n DropdownMenuContent,\r\n DropdownMenuItem,\r\n DropdownMenuTrigger,\r\n} from \"@/components/ui/dropdown-menu\"\r\nimport { CtaExternal } from \"@/components/ui/cta-button\"\r\nimport { LanguageSwitcher } from \"@/components/navigation/language-switcher\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface NavItem {\r\n label: string\r\n href: string\r\n children?: NavItem[]\r\n}\r\n\r\nexport interface NavbarProps {\r\n logo: { src?: string; alt: string; href: string; initial?: string }\r\n items: NavItem[]\r\n cta: { label: string; href: string }\r\n locale: string\r\n locales: string[]\r\n localeNames: Record\r\n localeShort: Record\r\n siteName: string\r\n menuLabel?: string\r\n languageLabel?: string\r\n closeLabel?: string\r\n navLabel?: string\r\n mobileNavLabel?: string\r\n controls?: React.ReactNode\r\n}\r\n\r\nexport function Navbar({\r\n logo,\r\n items,\r\n cta,\r\n locale,\r\n locales,\r\n localeNames,\r\n localeShort,\r\n siteName,\r\n menuLabel = \"Menu\",\r\n languageLabel = \"Language\",\r\n closeLabel = \"Close\",\r\n navLabel = \"Main navigation\",\r\n mobileNavLabel = \"Mobile navigation\",\r\n controls,\r\n}: NavbarProps) {\r\n const pathname = usePathname()\r\n const [scrolled, setScrolled] = useState(false)\r\n const [mobileOpen, setMobileOpen] = useState(false)\r\n\r\n useEffect(() => {\r\n const onScroll = () => setScrolled(window.scrollY > 8)\r\n onScroll()\r\n window.addEventListener(\"scroll\", onScroll, { passive: true })\r\n return () => window.removeEventListener(\"scroll\", onScroll)\r\n }, [])\r\n\r\n const isActive = (href: string) => {\r\n if (href === `/${locale}`) return pathname === `/${locale}`\r\n return pathname.startsWith(href)\r\n }\r\n\r\n return (\r\n \r\n
\r\n \r\n {logo.src ? (\r\n {logo.alt}\r\n ) : (\r\n \r\n {logo.initial ?? siteName.charAt(0)}\r\n \r\n )}\r\n \r\n {siteName}\r\n \r\n \r\n\r\n \r\n\r\n
\r\n {controls}\r\n\r\n \r\n\r\n \r\n {cta.label}\r\n \r\n\r\n \r\n \r\n \r\n \r\n \r\n {menuLabel}\r\n
\r\n {siteName}\r\n setMobileOpen(false)}\r\n aria-label={closeLabel}\r\n className=\"inline-flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-muted\"\r\n >\r\n \r\n \r\n
\r\n \r\n
\r\n \r\n
\r\n
\r\n \r\n )\r\n}\r\n", "target": "src/components/navigation/navbar.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn", "cta-button", "language-switcher", "dropdown-menu", "sheet" ] }, { "name": "manage-cookies-button", "type": "registry:ui", "title": "Manage Cookies Button", "description": "Client-side button that dispatches a manage-cookies event for the cookie banner.", "files": [ { "path": "registry/components/navigation/manage-cookies-button.tsx", "type": "registry:component", "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface ManageCookiesButtonProps {\n label: string\n manageEvent?: string\n size?: \"xs\" | \"sm\"\n className?: string\n}\n\nexport function ManageCookiesButton({\n label,\n manageEvent = \"manage-cookies\",\n size = \"sm\",\n className,\n}: ManageCookiesButtonProps) {\n return (\n window.dispatchEvent(new Event(manageEvent))}\n className={cn(\n size === \"xs\" ? \"text-xs\" : \"text-sm\",\n \"text-muted-foreground transition-colors hover:text-primary\",\n className,\n )}\n >\n {label}\n \n )\n}\n", "target": "src/components/navigation/manage-cookies-button.tsx" } ] }, { "name": "footer", "type": "registry:block", "title": "Footer", "description": "Multi-column footer with brand, navigation columns, contact info, social icons, and legal links.", "files": [ { "path": "registry/components/navigation/footer.tsx", "type": "registry:component", "content": "import Image from \"next/image\"\r\nimport Link from \"next/link\"\r\nimport { Mail, MapPin, Phone, Clock } from \"lucide-react\"\r\nimport { type SocialPlatform, socialIconMap } from \"@/components/ui/social-icons\"\r\nimport { ManageCookiesButton } from \"@/components/navigation/manage-cookies-button\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface SocialLink {\r\n platform: SocialPlatform\r\n url: string\r\n}\r\n\r\nexport interface FooterColumn {\r\n title: string\r\n links: { label: string; href: string }[]\r\n}\r\n\r\nexport interface NewsletterProps {\r\n title: string\r\n placeholder: string\r\n buttonLabel: string\r\n action?: string\r\n}\r\n\r\n/**\r\n * Footer usage recipes:\r\n *\r\n * 1. Logo + dark footer (BBX, Soleva, Laveria, Osteoptimum)\r\n * brand.logo, variant=\"dark\", colors: { headings: \"primary\", icons: \"primary\" }\r\n * (requires a --dark token in globals.css; keep \"accent\" default if\r\n * --primary is dark and would blend into the footer background)\r\n *\r\n * 2. No logo + tagline (Café des Promeneurs)\r\n * brand.name + tagline, hideMonogram: true,\r\n * colors: { brandName: \"primary\" }, className: \"bg-secondary/60\"\r\n *\r\n * 3. Light default (factory-template)\r\n * No colors required — headings default to text-primary (readable on\r\n * all theme presets). Do NOT default to text-secondary: in shadcn\r\n * presets, --secondary is a surface color nearly invisible on light\r\n * backgrounds (medical, corporate, hospitality).\r\n */\r\nexport interface FooterProps {\r\n brand: {\r\n name: string\r\n description: string\r\n tagline?: string\r\n logo?: string\r\n initial?: string\r\n hideMonogram?: boolean\r\n /** @deprecated Use colors.brandName instead. */\r\n brandColor?: \"primary\" | \"foreground\"\r\n }\r\n columns: FooterColumn[]\r\n contact: {\r\n title?: string\r\n address?: string\r\n phone?: string\r\n phoneLabel?: string\r\n email?: string\r\n mapsUrl?: string\r\n hours?: string\r\n hoursLabel?: string\r\n /** Structured opening hours — days left, hours right, one row each. */\r\n hoursRows?: { days: string; hours: string }[]\r\n }\n socials?: SocialLink[]\r\n legal: {\r\n links: { label: string; href: string }[]\r\n copyright: string\r\n }\r\n attribution?: { text: string; href: string; logo?: string }\r\n newsletter?: NewsletterProps\r\n manageCookiesLabel?: string\r\n manageCookiesEvent?: string\r\n variant?: \"default\" | \"dark\"\r\n /** Column headings color. */\r\n accentColor?: \"accent\" | \"primary\" | \"secondary\" | \"foreground\"\r\n /** Contact icon color. */\r\n iconColor?: \"accent\" | \"primary\" | \"secondary\" | \"foreground\"\r\n /** Grouped color overrides (preferred over accentColor/iconColor/brandColor). */\r\n colors?: {\r\n headings?: \"accent\" | \"primary\" | \"secondary\" | \"foreground\"\r\n icons?: \"accent\" | \"primary\" | \"secondary\" | \"foreground\"\r\n brandName?: \"primary\" | \"foreground\"\r\n }\r\n className?: string\r\n}\r\n\r\nexport function Footer({\r\n brand,\r\n columns,\r\n contact,\r\n socials,\r\n legal,\r\n attribution,\r\n newsletter,\r\n manageCookiesLabel,\r\n manageCookiesEvent = \"manage-cookies\",\r\n variant = \"default\",\r\n accentColor,\r\n iconColor,\r\n colors,\r\n className,\r\n}: FooterProps) {\r\n const dark = variant === \"dark\"\r\n\r\n const root = dark\r\n ? \"bg-dark text-dark-foreground\"\r\n : \"border-t border-border bg-muted/50\"\r\n\r\n const colorClass = (color: \"accent\" | \"primary\" | \"secondary\" | \"foreground\" | undefined) =>\r\n color === \"primary\"\r\n ? \"text-primary\"\r\n : color === \"secondary\"\r\n ? \"text-secondary\"\r\n : color === \"foreground\"\r\n ? \"text-foreground\"\r\n : dark\r\n ? \"text-accent\"\r\n : \"text-primary\"\r\n\r\n const headingsColor = colors?.headings ?? accentColor\r\n const iconsColor = colors?.icons ?? iconColor\r\n const brandNameColor = colors?.brandName ?? brand.brandColor\r\n const accentClass = colorClass(headingsColor)\r\n\r\n const brandName =\r\n brandNameColor === \"primary\"\r\n ? \"text-primary\"\r\n : dark\r\n ? \"text-dark-foreground\"\r\n : \"text-foreground\"\r\n const description = dark ? \"text-dark-foreground/70\" : \"text-muted-foreground\"\r\n const heading = accentClass\r\n const icon = iconsColor ? colorClass(iconsColor) : accentClass\r\n const link = dark\r\n ? \"text-dark-foreground/70 transition-colors hover:text-secondary\"\r\n : \"text-muted-foreground transition-colors hover:text-primary\"\r\n const socialIcon = dark\r\n ? \"text-dark-foreground/80 transition-colors hover:text-secondary\"\r\n : \"text-muted-foreground transition-colors hover:text-primary\"\r\n const bottomBorder = dark ? \"border-dark-foreground/10\" : \"border-border\"\r\n const bottomText = dark ? \"text-dark-foreground/60\" : \"text-muted-foreground\"\r\n const bottomLink = dark\r\n ? \"transition-colors hover:text-secondary\"\r\n : \"transition-colors hover:text-primary\"\r\n\r\n const inputClass = dark\r\n ? \"border-dark-foreground/20 bg-dark-foreground/10 text-dark-foreground placeholder:text-dark-foreground/50\"\r\n : \"border-border bg-background text-foreground placeholder:text-muted-foreground\"\r\n\r\n return (\r\n
\r\n
\r\n
\r\n {/* Brand column */}\r\n
\r\n
\r\n {brand.logo ? (\r\n \r\n ) : brand.hideMonogram ? null : (\r\n \r\n {brand.initial ?? brand.name.charAt(0)}\r\n \r\n )}\r\n {brand.name}\r\n
\r\n {brand.tagline && (\r\n \r\n {brand.tagline}\r\n

\r\n )}\r\n

\r\n {brand.description}\r\n

\r\n {socials && socials.length > 0 && (\r\n
\r\n {socials.map((social) => {\r\n const Icon = socialIconMap[social.platform]\r\n return (\r\n \r\n \r\n \r\n )\r\n })}\r\n
\r\n )}\r\n {newsletter && (\r\n
\r\n

\r\n {newsletter.title}\r\n

\r\n
\r\n \r\n \r\n {newsletter.buttonLabel}\r\n \r\n
\r\n
\r\n )}\r\n
\r\n\r\n {/* Navigation columns */}\r\n {columns.map((col) => (\r\n
\r\n

\r\n {col.title}\r\n

\r\n
    \r\n {col.links.map((linkItem) => (\r\n
  • \r\n \r\n {linkItem.label}\r\n \r\n
  • \r\n ))}\r\n
\r\n
\r\n ))}\r\n\r\n {/* Contact column */}\r\n
\r\n

\r\n {contact.title}\r\n

\r\n
    \r\n {contact.address && (\r\n
  • \r\n {contact.mapsUrl ? (\r\n \r\n \r\n {contact.address}\r\n \r\n ) : (\r\n \r\n \r\n {contact.address}\r\n \r\n )}\r\n
  • \r\n )}\r\n {contact.phone && (\r\n
  • \r\n \r\n \r\n {contact.phone}\r\n {contact.phoneLabel && (\r\n <>\r\n · \r\n \r\n {contact.phoneLabel}\r\n \r\n \r\n )}\r\n \r\n
  • \r\n )}\r\n {contact.email && (\r\n
  • \r\n \r\n \r\n {contact.email}\r\n \r\n
  • \r\n )}\r\n {(contact.hours || contact.hoursLabel) && (\r\n
  • \r\n \r\n \r\n \r\n {contact.hoursLabel && (\r\n {contact.hoursLabel}\r\n )}\r\n {contact.hoursLabel && contact.hours && : }\r\n {contact.hours && {contact.hours}}\r\n \r\n \r\n
  • \r\n )}\r\n
\r\n
\r\n
\r\n
\r\n\r\n {/* Bottom bar */}\r\n
\r\n
\r\n
\r\n {legal.copyright}\r\n \r\n
\r\n\r\n {attribution && (\r\n
\r\n \r\n {attribution.logo && (\r\n \r\n )}\r\n {attribution.text}\r\n \r\n
\r\n )}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/navigation/footer.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn", "social-icons", "manage-cookies-button" ] }, { "name": "home-hero", "type": "registry:block", "title": "Home Hero", "description": "Full-viewport hero section with background image, gradient overlay, and CTA buttons.", "files": [ { "path": "registry/components/sections/home-hero.tsx", "type": "registry:component", "content": "import { HeroAnimation } from \"@/components/ui/animations\";\r\nimport { CtaExternal, CtaLink } from \"@/components/ui/cta-button\";\r\nimport { cn } from \"@/lib/utils\";\r\nimport Image from \"next/image\";\r\n\r\nexport interface HomeHeroProps {\r\n eyebrow?: string;\r\n title: string;\r\n subtitle?: string;\r\n primaryCta: { label: string; href: string; external?: boolean };\r\n secondaryCta?: { label: string; href: string; external?: boolean };\r\n backgroundImage?: string;\r\n overlayClass?: string;\r\n className?: string;\r\n}\r\n\r\nexport function HomeHero({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n primaryCta,\r\n secondaryCta,\r\n backgroundImage,\r\n overlayClass,\r\n className,\r\n}: HomeHeroProps) {\r\n return (\r\n \r\n {backgroundImage && (\r\n \r\n )}\r\n
\r\n\r\n
\r\n \r\n
\r\n {eyebrow && (\r\n \r\n {eyebrow}\r\n \r\n )}\r\n

\r\n {title}\r\n

\r\n {subtitle && (\r\n

\r\n {subtitle}\r\n

\r\n )}\r\n
\r\n {primaryCta.external ? (\r\n \r\n {primaryCta.label}\r\n \r\n ) : (\r\n \r\n {primaryCta.label}\r\n \r\n )}\r\n {secondaryCta &&\r\n (secondaryCta.external ? (\r\n \r\n {secondaryCta.label}\r\n \r\n ) : (\r\n \r\n {secondaryCta.label}\r\n \r\n ))}\r\n
\r\n
\r\n
\r\n
\r\n \r\n );\r\n}\r\n", "target": "src/components/sections/home-hero.tsx" } ], "dependencies": [ "next" ], "registryDependencies": [ "cn", "cta-button", "animations" ] }, { "name": "page-hero", "type": "registry:block", "title": "Page Hero", "description": "Inner page hero with optional background image and integrated breadcrumb.", "files": [ { "path": "registry/components/sections/page-hero.tsx", "type": "registry:component", "content": "import Image from \"next/image\"\r\nimport { Breadcrumb, type Crumb } from \"@/components/ui/breadcrumb\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface PageHeroProps {\r\n title: string\r\n subtitle?: string\r\n eyebrow?: string\r\n backgroundImage?: string\r\n overlayClass?: string\r\n homeLabel: string\r\n breadcrumbs: Crumb[]\r\n className?: string\r\n}\r\n\r\nexport function PageHero({\r\n title,\r\n subtitle,\r\n eyebrow,\r\n backgroundImage,\r\n overlayClass,\r\n homeLabel,\r\n breadcrumbs,\r\n className,\r\n}: PageHeroProps) {\r\n const hasImage = !!backgroundImage\r\n\r\n return (\r\n
\r\n {hasImage && (\r\n <>\r\n \r\n
\r\n \r\n )}\r\n\r\n
\r\n \r\n\r\n
\r\n {eyebrow && (\r\n \r\n {eyebrow}\r\n \r\n )}\r\n \r\n {title}\r\n \r\n {subtitle && (\r\n \r\n {subtitle}\r\n

\r\n )}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/page-hero.tsx" } ], "dependencies": [ "next" ], "registryDependencies": [ "cn", "breadcrumb" ] }, { "name": "cta-band", "type": "registry:block", "title": "CTA Band", "description": "Full-width call-to-action banner with title, description, button, and color variant.", "files": [ { "path": "registry/components/sections/cta-band.tsx", "type": "registry:component", "content": "import { CtaLink, CtaExternal } from \"@/components/ui/cta-button\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface CtaBandProps {\r\n title: string\r\n description?: string\r\n cta: { label: string; href: string; external?: boolean }\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function CtaBand({ title, description, cta, variant = \"primary\", className }: CtaBandProps) {\r\n const colors = sectionVariantClasses[variant]\r\n const isDark = variant === \"primary\" || variant === \"secondary\"\r\n const ctaVariant = isDark ? \"onDark\" : \"primary\"\r\n\r\n return (\r\n
\r\n
\r\n

\r\n {title}\r\n

\r\n {description && (\r\n

\r\n {description}\r\n

\r\n )}\r\n
\r\n {cta.external ? (\r\n \r\n {cta.label}\r\n \r\n ) : (\r\n \r\n {cta.label}\r\n \r\n )}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/cta-band.tsx" } ], "registryDependencies": [ "cn", "cta-button", "section-variants" ] }, { "name": "trust-section", "type": "registry:block", "title": "Trust Section", "description": "Social proof section with icon grid for expertise, responsiveness, and quality badges.", "files": [ { "path": "registry/components/sections/trust-section.tsx", "type": "registry:component", "content": "import type { LucideIcon } from \"lucide-react\"\r\nimport { SectionHeading } from \"@/components/ui/section-heading\"\r\nimport { Reveal } from \"@/components/ui/reveal\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface TrustItem {\r\n icon: LucideIcon\r\n title: string\r\n description: string\r\n}\r\n\r\nexport interface TrustSectionProps {\r\n eyebrow?: string\r\n title: string\r\n subtitle?: string\r\n items: TrustItem[]\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function TrustSection({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n items,\r\n variant = \"muted\",\r\n className,\r\n}: TrustSectionProps) {\r\n const colors = sectionVariantClasses[variant]\r\n\r\n return (\r\n
\r\n
\r\n \r\n
\r\n {items.map((item, i) => {\r\n const Icon = item.icon\r\n const isDark = variant === \"primary\" || variant === \"secondary\"\r\n const isAlt = i % 2 === 1\r\n const badgeBg = isAlt ? (isDark ? \"bg-primary-foreground/15\" : \"bg-secondary/15\") : colors.iconBadge\r\n const badgeIcon = isAlt ? (isDark ? \"text-primary-foreground/80\" : \"text-secondary\") : colors.iconColor\r\n return (\r\n \r\n
\r\n
\r\n \r\n
\r\n

{item.title}

\r\n

\r\n {item.description}\r\n

\r\n
\r\n
\r\n )\r\n })}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/trust-section.tsx" } ], "dependencies": [ "lucide-react" ], "registryDependencies": [ "cn", "section-heading", "reveal", "section-variants" ] }, { "name": "service-card", "type": "registry:block", "title": "Service Card", "description": "Service card with image banner, floating icon, hover effect, and link.", "files": [ { "path": "registry/components/sections/service-card.tsx", "type": "registry:component", "content": "import Link from \"next/link\"\r\nimport type { LucideIcon } from \"lucide-react\"\r\nimport { ArrowRight } from \"lucide-react\"\r\nimport { ImageWithFallback } from \"@/components/ui/image-with-fallback\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface ServiceCardProps {\r\n title: string\r\n description: string\r\n image: string\r\n href: string\r\n icon?: LucideIcon\r\n iconVariant?: \"primary\" | \"secondary\" | \"accent\"\r\n imageAlt?: string\r\n readMoreLabel?: string\r\n fallbackLabel?: string\r\n className?: string\r\n}\r\n\r\nconst iconVariantClasses = {\r\n primary: { badge: \"bg-primary\", icon: \"text-primary-foreground\" },\r\n secondary: { badge: \"bg-secondary\", icon: \"text-secondary-foreground\" },\r\n accent: { badge: \"bg-accent\", icon: \"text-accent-foreground\" },\r\n} as const\r\n\r\nexport function ServiceCard({\r\n title,\r\n description,\r\n image,\r\n href,\r\n icon: Icon,\r\n iconVariant = \"primary\",\r\n imageAlt,\r\n readMoreLabel = \"Read more\",\r\n fallbackLabel,\r\n className,\r\n}: ServiceCardProps) {\r\n const iv = iconVariantClasses[iconVariant]\r\n\r\n return (\r\n \r\n
\r\n \r\n
\r\n {Icon && (\r\n
\r\n \r\n
\r\n )}\r\n
\r\n

{title}

\r\n

\r\n {description}\r\n

\r\n \r\n {readMoreLabel}\r\n \r\n \r\n
\r\n \r\n )\r\n}\r\n", "target": "src/components/sections/service-card.tsx" } ], "dependencies": [ "lucide-react", "next" ], "registryDependencies": [ "cn", "image-with-fallback" ] }, { "name": "services-grid", "type": "registry:block", "title": "Services Grid", "description": "Responsive grid of service cards with section heading and color variant.", "files": [ { "path": "registry/components/sections/services-grid.tsx", "type": "registry:component", "content": "import type { LucideIcon } from \"lucide-react\"\r\nimport { SectionHeading } from \"@/components/ui/section-heading\"\r\nimport { ServiceCard } from \"@/components/sections/service-card\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface ServiceItem {\r\n title: string\r\n description: string\r\n image: string\r\n href: string\r\n icon?: LucideIcon\r\n}\r\n\r\nexport interface ServicesGridProps {\r\n eyebrow?: string\r\n title?: string\r\n subtitle?: string\r\n services: ServiceItem[]\r\n readMoreLabel?: string\r\n fallbackLabel?: string\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function ServicesGrid({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n services,\r\n readMoreLabel,\r\n fallbackLabel,\r\n variant = \"default\",\r\n className,\r\n}: ServicesGridProps) {\r\n const colors = sectionVariantClasses[variant]\r\n\r\n return (\r\n
\r\n
\r\n {title && }\r\n
\r\n {services.map((service, i) => {\r\n const iconVariants = [\"primary\", \"secondary\", \"accent\"] as const\r\n return (\r\n \r\n )\r\n })}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/services-grid.tsx" } ], "registryDependencies": [ "cn", "service-card", "section-heading", "section-variants" ] }, { "name": "faq-list", "type": "registry:block", "title": "FAQ List", "description": "Accordion FAQ section with optional category filter tabs and color variant.", "files": [ { "path": "registry/components/sections/faq-list.tsx", "type": "registry:component", "content": "\"use client\"\r\n\r\nimport { useState } from \"react\"\r\nimport { SectionHeading } from \"@/components/ui/section-heading\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface FaqItem {\r\n question: string\r\n answer: string\r\n category?: string\r\n}\r\n\r\nexport interface FaqListProps {\r\n eyebrow?: string\r\n title: string\r\n subtitle?: string\r\n items: FaqItem[]\r\n allLabel?: string\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function FaqList({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n items,\r\n allLabel = \"All\",\r\n variant = \"default\",\r\n className,\r\n}: FaqListProps) {\r\n const categories = Array.from(new Set(items.map((f) => f.category).filter(Boolean))) as string[]\r\n const hasCategories = categories.length > 1\r\n const [active, setActive] = useState(null)\r\n const [openIndex, setOpenIndex] = useState(null)\r\n\r\n const colors = sectionVariantClasses[variant]\r\n const filtered = active ? items.filter((f) => f.category === active) : items\r\n\r\n return (\r\n
\r\n
\r\n \r\n\r\n {hasCategories && (\r\n
\r\n setActive(null)}\r\n className={cn(\r\n \"rounded-full px-4 py-2 text-sm font-medium transition-colors\",\r\n active === null\r\n ? \"bg-primary text-primary-foreground\"\r\n : \"bg-muted text-muted-foreground hover:bg-muted/80\",\r\n )}\r\n >\r\n {allLabel}\r\n \r\n {categories.map((cat) => (\r\n setActive(cat)}\r\n className={cn(\r\n \"rounded-full px-4 py-2 text-sm font-medium transition-colors\",\r\n active === cat\r\n ? \"bg-primary text-primary-foreground\"\r\n : \"bg-muted text-muted-foreground hover:bg-muted/80\",\r\n )}\r\n >\r\n {cat}\r\n \r\n ))}\r\n
\r\n )}\r\n\r\n
\r\n {filtered.map((faq, i) => {\r\n const isOpen = openIndex === i\r\n return (\r\n
\r\n setOpenIndex(isOpen ? null : i)}\r\n className=\"flex w-full items-center justify-between gap-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\"\r\n aria-expanded={isOpen}\r\n >\r\n {faq.question}\r\n \r\n {isOpen ? \"−\" : \"+\"}\r\n \r\n \r\n {isOpen && (\r\n

\r\n {faq.answer}\r\n

\r\n )}\r\n
\r\n )\r\n })}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/faq-list.tsx" } ], "registryDependencies": [ "cn", "section-heading", "section-variants" ] }, { "name": "testimonials", "type": "registry:block", "title": "Testimonials", "description": "Social proof section displaying client testimonials with color variant.", "files": [ { "path": "registry/components/sections/testimonials.tsx", "type": "registry:component", "content": "import { Quote } from \"lucide-react\"\r\nimport { SectionHeading } from \"@/components/ui/section-heading\"\r\nimport { Reveal } from \"@/components/ui/reveal\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface Testimonial {\r\n quote: string\r\n name: string\r\n role?: string\r\n company?: string\r\n}\r\n\r\nexport interface TestimonialsProps {\r\n eyebrow?: string\r\n title: string\r\n subtitle?: string\r\n items: Testimonial[]\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function Testimonials({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n items,\r\n variant = \"default\",\r\n className,\r\n}: TestimonialsProps) {\r\n const colors = sectionVariantClasses[variant]\r\n const isDark = variant === \"primary\" || variant === \"secondary\"\r\n\r\n return (\r\n
\r\n
\r\n \r\n
\r\n {items.map((t, i) => (\r\n \r\n
\r\n \r\n
\r\n “{t.quote}”\r\n
\r\n
\r\n

{t.name}

\r\n {(t.role || t.company) && (\r\n

\r\n {[t.role, t.company].filter(Boolean).join(\" — \")}\r\n

\r\n )}\r\n
\r\n
\r\n
\r\n ))}\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/testimonials.tsx" } ], "dependencies": [ "lucide-react" ], "registryDependencies": [ "cn", "section-heading", "reveal", "section-variants" ] }, { "name": "method-steps", "type": "registry:block", "title": "Method Steps", "description": "Multi-step process visualization with numbered steps, descriptions, and color variant.", "files": [ { "path": "registry/components/sections/method-steps.tsx", "type": "registry:component", "content": "import { SectionHeading } from \"@/components/ui/section-heading\"\r\nimport { Reveal } from \"@/components/ui/reveal\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface Step {\r\n title: string\r\n description: string\r\n}\r\n\r\nexport interface MethodStepsProps {\r\n eyebrow?: string\r\n title: string\r\n subtitle?: string\r\n steps: Step[]\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function MethodSteps({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n steps,\r\n variant = \"muted\",\r\n className,\r\n}: MethodStepsProps) {\r\n const colors = sectionVariantClasses[variant]\r\n\r\n return (\r\n
\r\n
\r\n \r\n
\r\n \r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/method-steps.tsx" } ], "registryDependencies": [ "cn", "section-heading", "reveal", "section-variants" ] }, { "name": "pricing-table", "type": "registry:block", "title": "Pricing Table", "description": "Service pricing table with name, duration, price, description, and color variant.", "files": [ { "path": "registry/components/sections/pricing-table.tsx", "type": "registry:component", "content": "import { SectionHeading } from \"@/components/ui/section-heading\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface PricingItem {\r\n code?: string\r\n name: string\r\n description?: string\r\n duration?: string\r\n price: string\r\n}\r\n\r\nexport const DEFAULT_PRICING_COLUMNS = {\r\n code: \"Code\",\r\n name: \"Service\",\r\n description: \"Description\",\r\n duration: \"Duration\",\r\n price: \"Price\",\r\n}\r\n\r\ninterface PricingTableProps {\r\n eyebrow?: string\r\n title: string\r\n subtitle?: string\r\n items: PricingItem[]\r\n insuranceNote?: string\r\n cancellationNote?: string\r\n columns?: Partial\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function PricingTable({\r\n eyebrow,\r\n title,\r\n subtitle,\r\n items,\r\n insuranceNote,\r\n cancellationNote,\r\n columns,\r\n variant = \"default\",\r\n className,\r\n}: PricingTableProps) {\r\n const colors = sectionVariantClasses[variant]\r\n const cols = { ...DEFAULT_PRICING_COLUMNS, ...columns }\r\n\r\n const hasCode = items.some((i) => i.code)\r\n const hasDuration = items.some((i) => i.duration)\r\n const hasDescription = items.some((i) => i.description)\r\n\r\n return (\r\n
\r\n
\r\n \r\n
\r\n \r\n \r\n \r\n {hasCode && (\r\n \r\n )}\r\n \r\n {hasDescription && (\r\n \r\n )}\r\n {hasDuration && (\r\n \r\n )}\r\n \r\n \r\n \r\n \r\n {items.map((item) => (\r\n \r\n {hasCode && (\r\n \r\n )}\r\n \r\n {hasDescription && (\r\n \r\n )}\r\n {hasDuration && (\r\n \r\n )}\r\n \r\n \r\n ))}\r\n \r\n
{cols.code}{cols.name}\r\n {cols.description}\r\n {cols.duration}\r\n {cols.price}\r\n
\r\n {item.code}\r\n {item.name}\r\n {item.description}\r\n {item.duration}\r\n {item.price}\r\n
\r\n
\r\n {(insuranceNote || cancellationNote) && (\r\n
\r\n {insuranceNote &&

{insuranceNote}

}\r\n {cancellationNote &&

{cancellationNote}

}\r\n
\r\n )}\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/pricing-table.tsx" } ], "registryDependencies": [ "cn", "section-heading", "section-variants" ] }, { "name": "contact-info", "type": "registry:block", "title": "Contact Info", "description": "Contact information section with address, phone, email, hours, map, and color variant.", "files": [ { "path": "registry/components/sections/contact-info.tsx", "type": "registry:component", "content": "import { MapPin, Phone, Mail, Clock } from \"lucide-react\"\r\nimport { type SocialPlatform, socialIconMap } from \"@/components/ui/social-icons\"\r\nimport {\r\n type SectionVariant,\r\n sectionVariantClasses,\r\n} from \"@/lib/section-variants\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface ContactInfoProps {\r\n title?: string\r\n address?: string\r\n mapsUrl?: string\r\n mapsEmbed?: string\r\n phone?: string\r\n email?: string\r\n hours?: { label: string; value: string }[]\r\n socials?: { platform: SocialPlatform; url: string }[]\r\n hoursLabel?: string\r\n mapPlaceholder?: string\r\n mapsTitle?: string\r\n variant?: SectionVariant\r\n className?: string\r\n}\r\n\r\nexport function ContactInfo({\r\n title,\r\n address,\r\n mapsUrl,\r\n mapsEmbed,\r\n phone,\r\n email,\r\n hours,\r\n socials,\r\n hoursLabel = \"Hours\",\r\n mapPlaceholder,\r\n mapsTitle = \"Google Maps\",\r\n variant = \"default\",\r\n className,\r\n}: ContactInfoProps) {\r\n const colors = sectionVariantClasses[variant]\r\n\r\n return (\r\n
\r\n
\r\n
\r\n
\r\n {title && (\r\n

{title}

\r\n )}\r\n\r\n
\r\n {address && (\r\n
\r\n \r\n {mapsUrl ? (\r\n \r\n {address}\r\n \r\n ) : (\r\n {address}\r\n )}\r\n
\r\n )}\r\n\r\n {phone && (\r\n
\r\n \r\n \r\n {phone}\r\n \r\n
\r\n )}\r\n\r\n {email && (\r\n
\r\n \r\n \r\n {email}\r\n \r\n
\r\n )}\r\n
\r\n\r\n {hours && hours.length > 0 && (\r\n
\r\n
\r\n \r\n {hoursLabel}\r\n
\r\n
\r\n {hours.map((h) => (\r\n
\r\n
{h.label}
\r\n
{h.value}
\r\n
\r\n ))}\r\n
\r\n
\r\n )}\r\n\r\n {socials && socials.length > 0 && (\r\n
\r\n {socials.map((s) => {\r\n const Icon = socialIconMap[s.platform]\r\n return (\r\n \r\n \r\n \r\n )\r\n })}\r\n
\r\n )}\r\n
\r\n\r\n
\r\n {mapsEmbed ? (\r\n \r\n ) : (\r\n
\r\n

\r\n {mapPlaceholder}\r\n

\r\n
\r\n )}\r\n
\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/contact-info.tsx" } ], "dependencies": [ "lucide-react" ], "registryDependencies": [ "cn", "social-icons", "section-variants" ] }, { "name": "legal-page", "type": "registry:block", "title": "Legal Page", "description": "Prose layout for legal pages (CGV, mentions légales, confidentialité).", "files": [ { "path": "registry/components/sections/legal-page.tsx", "type": "registry:component", "content": "import { cn } from \"@/lib/utils\"\r\n\r\nexport interface LegalSection {\r\n title: string\r\n content: string\r\n}\r\n\r\nexport interface LegalPageProps {\r\n title: string\r\n updatedAt?: string\r\n updatedLabel?: string\r\n dateSeparator?: string\r\n intro?: string\r\n sections: LegalSection[]\r\n className?: string\r\n}\r\n\r\nexport function LegalPage({\r\n title,\r\n updatedAt,\r\n updatedLabel = \"Last updated\",\r\n dateSeparator = \" : \",\r\n intro,\r\n sections,\r\n className,\r\n}: LegalPageProps) {\r\n return (\r\n
\r\n
\r\n
\r\n

{title}

\r\n {updatedAt && (\r\n

\r\n {updatedLabel}{dateSeparator}{updatedAt}\r\n

\r\n )}\r\n {intro && (\r\n

{intro}

\r\n )}\r\n
\r\n {sections.map((section) => (\r\n
\r\n

{section.title}

\r\n
\r\n

{section.content}

\r\n
\r\n
\r\n ))}\r\n
\r\n
\r\n
\r\n
\r\n )\r\n}\r\n", "target": "src/components/sections/legal-page.tsx" } ], "registryDependencies": [ "cn" ] }, { "name": "not-found-page", "type": "registry:block", "title": "404 Page", "description": "Styled 404 page with business-themed messaging and CTA button.", "files": [ { "path": "registry/components/layouts/not-found-page.tsx", "type": "registry:component", "content": "import { CtaLink } from \"@/components/ui/cta-button\"\r\nimport { cn } from \"@/lib/utils\"\r\n\r\nexport interface NotFoundPageProps {\r\n title?: string\r\n description?: string\r\n ctaLabel?: string\r\n ctaHref?: string\r\n errorCode?: string\r\n className?: string\r\n}\r\n\r\nexport function NotFoundPage({\r\n title = \"Page not found\",\r\n description = \"The page you are looking for does not exist or has been moved.\",\r\n ctaLabel = \"Back to home\",\r\n ctaHref = \"/\",\r\n errorCode = \"404\",\r\n className,\r\n}: NotFoundPageProps) {\r\n return (\r\n \r\n {errorCode}\r\n

{title}

\r\n

{description}

\r\n
\r\n \r\n {ctaLabel}\r\n \r\n
\r\n \r\n )\r\n}\r\n", "target": "src/components/layouts/not-found-page.tsx" } ], "registryDependencies": [ "cn", "cta-button" ] }, { "name": "theme-presets", "type": "registry:lib", "title": "Theme Presets", "description": "Industry + mood based theme preset system with 5 starter presets (example-colors, sage-clinic, swiss-corporate, alpine-table, solar-libre).", "files": [ { "path": "registry/lib/themes/index.ts", "type": "registry:lib", "content": "import exampleColors from \"./presets/example-colors.json\"\nimport sageClinic from \"./presets/sage-clinic.json\"\nimport swissCorporate from \"./presets/swiss-corporate.json\"\nimport alpineTable from \"./presets/alpine-table.json\"\nimport solarLibre from \"./presets/solar-libre.json\"\nimport medieval from \"./presets/medieval.json\"\nimport houseOfTheDragon from \"./presets/house-of-the-dragon.json\"\nimport worldOfWarcraft from \"./presets/world-of-warcraft.json\"\nimport banners from \"./presets/banners.json\"\nimport bannersDark from \"./presets/banners-dark.json\"\n\nexport type ThemeTokenKey =\n | \"--background\"\n | \"--foreground\"\n | \"--primary\"\n | \"--primary-foreground\"\n | \"--secondary\"\n | \"--secondary-foreground\"\n | \"--accent\"\n | \"--accent-foreground\"\n | \"--muted\"\n | \"--muted-foreground\"\n | \"--border\"\n | \"--ring\"\n | \"--dark-foreground\"\n\nexport type ThemeTokens = Partial>\n\nexport interface ThemePreset {\n id: string\n label: string\n industry: string\n mood: string\n tokens: ThemeTokens\n}\n\nexport const defaultPresetId = \"example-colors\"\n\nexport const presets: ThemePreset[] = [\n exampleColors as ThemePreset,\n sageClinic as ThemePreset,\n swissCorporate as ThemePreset,\n alpineTable as ThemePreset,\n solarLibre as ThemePreset,\n medieval as ThemePreset,\n houseOfTheDragon as ThemePreset,\n worldOfWarcraft as ThemePreset,\n banners as ThemePreset,\n bannersDark as ThemePreset,\n]\n\nexport function getPreset(id: string): ThemePreset | undefined {\n return presets.find((p) => p.id === id)\n}\n\nexport function groupByIndustry(): Record {\n const groups: Record = {}\n for (const preset of presets) {\n if (!groups[preset.industry]) {\n groups[preset.industry] = []\n }\n groups[preset.industry].push(preset)\n }\n return groups\n}\n", "target": "src/lib/themes/index.ts" }, { "path": "registry/lib/themes/presets/example-colors.json", "type": "registry:lib", "content": "{\n \"id\": \"example-colors\",\n \"label\": \"Example Colors\",\n \"industry\": \"default\",\n \"mood\": \"default\",\n \"tokens\": {\n \"--background\": \"#ffffff\",\n \"--foreground\": \"#171717\",\n \"--primary\": \"#2563eb\",\n \"--primary-foreground\": \"#ffffff\",\n \"--secondary\": \"#f59e0b\",\n \"--secondary-foreground\": \"#0f172a\",\n \"--accent\": \"#f1f5f9\",\n \"--accent-foreground\": \"#0f172a\",\n \"--muted\": \"#f1f5f9\",\n \"--muted-foreground\": \"#64748b\",\n \"--border\": \"#e2e8f0\",\n \"--ring\": \"#2563eb\",\n \"--dark-foreground\": \"#fafafa\"\n }\n}\n", "target": "src/lib/themes/presets/example-colors.json" }, { "path": "registry/lib/themes/presets/sage-clinic.json", "type": "registry:lib", "content": "{\n \"id\": \"sage-clinic\",\n \"label\": \"Sage Clinic\",\n \"industry\": \"medical\",\n \"mood\": \"soft\",\n \"tokens\": {\n \"--background\": \"#FAF8F5\",\n \"--foreground\": \"#2C2A25\",\n \"--primary\": \"#6B7F5E\",\n \"--primary-foreground\": \"#FFFFFF\",\n \"--secondary\": \"#F0EBE3\",\n \"--secondary-foreground\": \"#2C2A25\",\n \"--accent\": \"#D4C5A9\",\n \"--accent-foreground\": \"#2C2A25\",\n \"--muted\": \"#F0EBE3\",\n \"--muted-foreground\": \"#78756E\",\n \"--border\": \"#E5DFD5\",\n \"--ring\": \"#6B7F5E\",\n \"--dark-foreground\": \"#FAF8F5\"\n }\n}\n", "target": "src/lib/themes/presets/sage-clinic.json" }, { "path": "registry/lib/themes/presets/swiss-corporate.json", "type": "registry:lib", "content": "{\n \"id\": \"swiss-corporate\",\n \"label\": \"Swiss Corporate\",\n \"industry\": \"finance\",\n \"mood\": \"corporate\",\n \"tokens\": {\n \"--background\": \"#ffffff\",\n \"--foreground\": \"#35322e\",\n \"--primary\": \"#729fc6\",\n \"--primary-foreground\": \"#ffffff\",\n \"--secondary\": \"#f3f1ee\",\n \"--secondary-foreground\": \"#35322e\",\n \"--accent\": \"#729fc6\",\n \"--accent-foreground\": \"#ffffff\",\n \"--muted\": \"#f3f1ee\",\n \"--muted-foreground\": \"#6b6760\",\n \"--border\": \"#e6e3df\",\n \"--ring\": \"#729fc6\",\n \"--dark-foreground\": \"#ffffff\"\n }\n}\n", "target": "src/lib/themes/presets/swiss-corporate.json" }, { "path": "registry/lib/themes/presets/alpine-table.json", "type": "registry:lib", "content": "{\n \"id\": \"alpine-table\",\n \"label\": \"Alpine Table\",\n \"industry\": \"hospitality\",\n \"mood\": \"warm\",\n \"tokens\": {\n \"--background\": \"#FAF6EE\",\n \"--foreground\": \"#3B3630\",\n \"--primary\": \"#2E5D43\",\n \"--primary-foreground\": \"#FBF6EC\",\n \"--secondary\": \"#EFE7D6\",\n \"--secondary-foreground\": \"#3B3630\",\n \"--accent\": \"#C66B3E\",\n \"--accent-foreground\": \"#FFFBF4\",\n \"--muted\": \"#EEE7DA\",\n \"--muted-foreground\": \"#7A6F5E\",\n \"--border\": \"#E0D8C6\",\n \"--ring\": \"#2E5D43\",\n \"--dark-foreground\": \"#FBF6EC\"\n }\n}\n", "target": "src/lib/themes/presets/alpine-table.json" }, { "path": "registry/lib/themes/presets/solar-libre.json", "type": "registry:lib", "content": "{\n \"id\": \"solar-libre\",\n \"label\": \"Solar Libre\",\n \"industry\": \"energy\",\n \"mood\": \"bold\",\n \"tokens\": {\n \"--background\": \"#ffffff\",\n \"--foreground\": \"#1f2a2a\",\n \"--primary\": \"#ff803e\",\n \"--primary-foreground\": \"#ffffff\",\n \"--secondary\": \"#005f60\",\n \"--secondary-foreground\": \"#ffffff\",\n \"--accent\": \"#fff4ec\",\n \"--accent-foreground\": \"#005f60\",\n \"--muted\": \"#eef4f3\",\n \"--muted-foreground\": \"#5a6b6a\",\n \"--border\": \"#e2e8f0\",\n \"--ring\": \"#ff803e\",\n \"--dark-foreground\": \"#fdf6f0\"\n }\n}\n", "target": "src/lib/themes/presets/solar-libre.json" } ] }, { "name": "theme-provider", "type": "registry:ui", "title": "Theme Provider", "description": "React context provider that applies a theme preset by injecting CSS custom properties on . Supports preview mode (localStorage) and locked mode (fixed theme).", "files": [ { "path": "registry/components/ui/theme-provider.tsx", "type": "registry:component", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n type ThemePreset,\n presets as allPresets,\n defaultPresetId,\n getPreset,\n groupByIndustry,\n} from \"@/lib/themes\"\n\ninterface ThemeContextValue {\n preset: ThemePreset\n presetId: string\n setPreset: (id: string) => void\n isLocked: boolean\n allPresets: ThemePreset[]\n groupedPresets: Record\n}\n\nconst ThemeContext = React.createContext(null)\n\nexport interface ThemeProviderProps {\n children: React.ReactNode\n defaultPreset?: string\n locked?: boolean\n storageKey?: string\n}\n\nexport function ThemeProvider({\n children,\n defaultPreset = defaultPresetId,\n locked = false,\n storageKey = \"theme-preset\",\n}: ThemeProviderProps) {\n const [presetId, setPresetId] = React.useState(defaultPreset)\n\n React.useEffect(() => {\n if (locked) return\n try {\n const saved = localStorage.getItem(storageKey)\n if (saved && getPreset(saved)) {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time sync from localStorage on mount\n setPresetId(saved)\n }\n } catch {\n // ignore storage errors (private mode, etc.)\n }\n }, [locked, storageKey])\n\n React.useEffect(() => {\n const preset = getPreset(presetId) ?? getPreset(defaultPreset)\n if (!preset) return\n const root = document.documentElement\n for (const [key, value] of Object.entries(preset.tokens)) {\n root.style.setProperty(key, value)\n }\n }, [presetId, defaultPreset])\n\n const setPreset = React.useCallback(\n (id: string) => {\n if (locked) return\n if (!getPreset(id)) return\n setPresetId(id)\n try {\n localStorage.setItem(storageKey, id)\n } catch {\n // ignore storage errors\n }\n },\n [locked, storageKey],\n )\n\n const preset = getPreset(presetId) ?? getPreset(defaultPreset)!\n const value = React.useMemo(\n () => ({\n preset,\n presetId: preset.id,\n setPreset,\n isLocked: locked,\n allPresets,\n groupedPresets: groupByIndustry(),\n }),\n [preset, setPreset, locked],\n )\n\n return {children}\n}\n\nexport function useTheme(): ThemeContextValue {\n const ctx = React.useContext(ThemeContext)\n if (!ctx) {\n throw new Error(\"useTheme must be used within a ThemeProvider\")\n }\n return ctx\n}\n", "target": "src/components/ui/theme-provider.tsx" } ], "registryDependencies": [ "cn", "theme-presets" ] }, { "name": "theme-switcher", "type": "registry:ui", "title": "Theme Switcher", "description": "Dropdown menu switcher for theme presets, grouped by industry. Only renders in preview mode (when theme is not locked).", "files": [ { "path": "registry/components/navigation/theme-switcher.tsx", "type": "registry:component", "content": "\"use client\"\n\nimport { Palette, Check } from \"lucide-react\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { cn } from \"@/lib/utils\"\nimport { useTheme } from \"@/components/ui/theme-provider\"\n\nexport interface ThemeSwitcherProps {\n label?: string\n ariaLabel?: string\n className?: string\n}\n\nfunction Swatch({ color }: { color: string }) {\n return (\n \n )\n}\n\nfunction capitalize(str: string): string {\n return str.charAt(0).toUpperCase() + str.slice(1)\n}\n\nexport function ThemeSwitcher({\n label,\n ariaLabel = \"Theme\",\n className,\n}: ThemeSwitcherProps) {\n const { isLocked, presetId, setPreset, groupedPresets } = useTheme()\n\n if (isLocked) return null\n\n return (\n \n \n \n {label && {label}}\n \n \n {Object.entries(groupedPresets).map(([industry, moods]) => {\n const industryColor = moods[0]?.tokens[\"--primary\"] ?? \"#000\"\n return (\n \n \n \n \n {capitalize(industry)}\n \n \n \n {moods.map((preset) => (\n setPreset(preset.id)}\n >\n \n \n {preset.label}\n \n {preset.id === presetId && (\n \n )}\n \n ))}\n \n \n )\n })}\n \n \n )\n}\n", "target": "src/components/navigation/theme-switcher.tsx" } ], "dependencies": [ "lucide-react" ], "registryDependencies": [ "cn", "dropdown-menu", "theme-provider" ] }, { "name": "font-presets", "type": "registry:lib", "title": "Font Presets", "description": "Font-family based preset system with 7 starters (Montserrat, Inter, Space Grotesk, Playfair, Source Serif 4, EB Garamond, JetBrains Mono). Grouped by family (sans/serif/mono).", "files": [ { "path": "registry/lib/fonts/index.ts", "type": "registry:lib", "content": "import montserrat from \"./presets/montserrat.json\"\nimport inter from \"./presets/inter.json\"\nimport playfairDisplay from \"./presets/playfair-display.json\"\nimport sourceSerif4 from \"./presets/source-serif-4.json\"\nimport spaceGrotesk from \"./presets/space-grotesk.json\"\nimport ebGaramond from \"./presets/eb-garamond.json\"\nimport jetbrainsMono from \"./presets/jetbrains-mono.json\"\nimport cinzel from \"./presets/cinzel.json\"\nimport cinzelDecorative from \"./presets/cinzel-decorative.json\"\nimport medievalsharp from \"./presets/medievalsharp.json\"\nimport pirataOne from \"./presets/pirata-one.json\"\nimport imFellEnglish from \"./presets/im-fell-english.json\"\nimport morpheus from \"./presets/morpheus.json\"\n\nexport type FontRole = \"heading\" | \"eyebrow\" | \"body\"\n\nexport interface FontPreset {\n id: string\n label: string\n family: \"sans\" | \"serif\" | \"mono\"\n category: string\n cssVar: string\n}\n\nexport interface FontConfig {\n heading: string\n eyebrow: string\n body: string\n}\n\nexport const defaultFontConfig: FontConfig = {\n heading: \"playfair-display\",\n eyebrow: \"montserrat\",\n body: \"montserrat\",\n}\n\nexport const fonts: FontPreset[] = [\n montserrat as FontPreset,\n inter as FontPreset,\n playfairDisplay as FontPreset,\n sourceSerif4 as FontPreset,\n spaceGrotesk as FontPreset,\n ebGaramond as FontPreset,\n jetbrainsMono as FontPreset,\n cinzel as FontPreset,\n cinzelDecorative as FontPreset,\n medievalsharp as FontPreset,\n pirataOne as FontPreset,\n imFellEnglish as FontPreset,\n morpheus as FontPreset,\n]\n\nexport function getFont(id: string): FontPreset | undefined {\n return fonts.find((f) => f.id === id)\n}\n\nexport function resolveConfig(config: Partial): FontConfig {\n return {\n heading: getFont(config.heading ?? \"\") ? config.heading! : defaultFontConfig.heading,\n eyebrow: getFont(config.eyebrow ?? \"\") ? config.eyebrow! : defaultFontConfig.eyebrow,\n body: getFont(config.body ?? \"\") ? config.body! : defaultFontConfig.body,\n }\n}\n\nexport function groupByFamily(): Record {\n const groups: Record = {}\n for (const font of fonts) {\n if (!groups[font.family]) {\n groups[font.family] = []\n }\n groups[font.family].push(font)\n }\n return groups\n}\n\nexport function groupByCategory(): Record {\n const groups: Record = {}\n for (const font of fonts) {\n if (!groups[font.category]) {\n groups[font.category] = []\n }\n groups[font.category].push(font)\n }\n return groups\n}\n", "target": "src/lib/fonts/index.ts" }, { "path": "registry/lib/fonts/presets/montserrat.json", "type": "registry:lib", "content": "{\n \"id\": \"montserrat\",\n \"label\": \"Montserrat\",\n \"family\": \"sans\",\n \"cssVar\": \"var(--font-montserrat), \\\"Montserrat\\\", ui-sans-serif, system-ui, -apple-system, sans-serif\",\n \"category\": \"Moderne\"\n}\n", "target": "src/lib/fonts/presets/montserrat.json" }, { "path": "registry/lib/fonts/presets/inter.json", "type": "registry:lib", "content": "{\n \"id\": \"inter\",\n \"label\": \"Inter\",\n \"family\": \"sans\",\n \"cssVar\": \"var(--font-inter), \\\"Inter\\\", ui-sans-serif, system-ui, -apple-system, sans-serif\",\n \"category\": \"Moderne\"\n}\n", "target": "src/lib/fonts/presets/inter.json" }, { "path": "registry/lib/fonts/presets/playfair-display.json", "type": "registry:lib", "content": "{\n \"id\": \"playfair-display\",\n \"label\": \"Playfair Display\",\n \"family\": \"serif\",\n \"cssVar\": \"var(--font-playfair), \\\"Playfair Display\\\", Georgia, \\\"Times New Roman\\\", serif\",\n \"category\": \"Classique\"\n}\n", "target": "src/lib/fonts/presets/playfair-display.json" }, { "path": "registry/lib/fonts/presets/source-serif-4.json", "type": "registry:lib", "content": "{\n \"id\": \"source-serif-4\",\n \"label\": \"Source Serif 4\",\n \"family\": \"serif\",\n \"cssVar\": \"var(--font-source-serif-4), \\\"Source Serif 4\\\", Georgia, \\\"Times New Roman\\\", serif\",\n \"category\": \"Classique\"\n}\n", "target": "src/lib/fonts/presets/source-serif-4.json" }, { "path": "registry/lib/fonts/presets/space-grotesk.json", "type": "registry:lib", "content": "{\n \"id\": \"space-grotesk\",\n \"label\": \"Space Grotesk\",\n \"family\": \"sans\",\n \"cssVar\": \"var(--font-space-grotesk), \\\"Space Grotesk\\\", ui-sans-serif, system-ui, -apple-system, sans-serif\",\n \"category\": \"Moderne\"\n}\n", "target": "src/lib/fonts/presets/space-grotesk.json" }, { "path": "registry/lib/fonts/presets/eb-garamond.json", "type": "registry:lib", "content": "{\n \"id\": \"eb-garamond\",\n \"label\": \"EB Garamond\",\n \"family\": \"serif\",\n \"cssVar\": \"var(--font-eb-garamond), \\\"EB Garamond\\\", Georgia, \\\"Times New Roman\\\", serif\",\n \"category\": \"Classique\"\n}\n", "target": "src/lib/fonts/presets/eb-garamond.json" }, { "path": "registry/lib/fonts/presets/jetbrains-mono.json", "type": "registry:lib", "content": "{\n \"id\": \"jetbrains-mono\",\n \"label\": \"JetBrains Mono\",\n \"family\": \"mono\",\n \"cssVar\": \"var(--font-jetbrains-mono), \\\"JetBrains Mono\\\", ui-monospace, \\\"SF Mono\\\", \\\"Cascadia Code\\\", \\\"Source Code Pro\\\", Menlo, Consolas, monospace\",\n \"category\": \"Mono\"\n}\n", "target": "src/lib/fonts/presets/jetbrains-mono.json" } ] }, { "name": "font-provider", "type": "registry:ui", "title": "Font Provider", "description": "React context provider that applies a font preset by injecting --font-sans and --font-heading CSS custom properties on . Supports preview mode (localStorage) and locked mode (fixed font).", "files": [ { "path": "registry/components/ui/font-provider.tsx", "type": "registry:component", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n type FontConfig,\n type FontPreset,\n type FontRole,\n fonts as allFonts,\n getFont,\n groupByFamily,\n resolveConfig,\n} from \"@/lib/fonts\"\n\ninterface FontContextValue {\n config: FontConfig\n getRoleFont: (role: FontRole) => FontPreset\n setRoleFont: (role: FontRole, id: string) => void\n isLocked: boolean\n allFonts: FontPreset[]\n groupedFonts: Record\n}\n\nconst FontContext = React.createContext(null)\n\nexport interface FontProviderProps {\n children: React.ReactNode\n defaultConfig?: Partial\n locked?: boolean\n storageKey?: string\n}\n\nexport function FontProvider({\n children,\n defaultConfig,\n locked = false,\n storageKey = \"font-config\",\n}: FontProviderProps) {\n const [config, setConfig] = React.useState(() =>\n resolveConfig(defaultConfig ?? {}),\n )\n\n React.useEffect(() => {\n if (locked) return\n try {\n const saved = localStorage.getItem(storageKey)\n if (saved) {\n const parsed = JSON.parse(saved) as Partial\n if (parsed && typeof parsed === \"object\") {\n setConfig(resolveConfig(parsed))\n }\n }\n } catch {\n // ignore storage errors (private mode, etc.)\n }\n }, [locked, storageKey])\n\n React.useEffect(() => {\n const heading = getFont(config.heading)\n const eyebrow = getFont(config.eyebrow)\n const body = getFont(config.body)\n if (!heading || !eyebrow || !body) return\n\n const id = \"font-config-style\"\n const existing = document.getElementById(id)\n if (existing) existing.remove()\n\n const style = document.createElement(\"style\")\n style.id = id\n style.textContent = [\n `body,.font-sans{font-family:${body.cssVar}!important}`,\n `.font-heading,h1,h2,h3,h4,h5,h6{font-family:${heading.cssVar}!important}`,\n `.font-eyebrow{font-family:${eyebrow.cssVar}!important}`,\n ].join(\"\")\n document.head.appendChild(style)\n\n return () => {\n const el = document.getElementById(id)\n if (el) el.remove()\n }\n }, [config])\n\n const setRoleFont = React.useCallback(\n (role: FontRole, id: string) => {\n if (locked) return\n if (!getFont(id)) return\n setConfig((prev) => {\n const next = { ...prev, [role]: id }\n try {\n localStorage.setItem(storageKey, JSON.stringify(next))\n } catch {\n // ignore storage errors\n }\n return next\n })\n },\n [locked, storageKey],\n )\n\n const value = React.useMemo(\n () => ({\n config,\n getRoleFont: (role) => getFont(config[role]) ?? allFonts[0],\n setRoleFont,\n isLocked: locked,\n allFonts,\n groupedFonts: groupByFamily(),\n }),\n [config, setRoleFont, locked],\n )\n\n return {children}\n}\n\nexport function useFont(): FontContextValue {\n const ctx = React.useContext(FontContext)\n if (!ctx) {\n throw new Error(\"useFont must be used within a FontProvider\")\n }\n return ctx\n}\n", "target": "src/components/ui/font-provider.tsx" } ], "registryDependencies": [ "cn", "font-presets" ] }, { "name": "font-switcher", "type": "registry:ui", "title": "Font Switcher", "description": "Dropdown menu switcher for font presets, grouped by family (Sans / Serif / Mono). Each item previews its own font. Only renders in preview mode.", "files": [ { "path": "registry/components/navigation/font-switcher.tsx", "type": "registry:component", "content": "\"use client\"\n\nimport { Type, Check, ChevronRight } from \"lucide-react\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport { cn } from \"@/lib/utils\"\nimport { type FontRole, groupByCategory } from \"@/lib/fonts\"\nimport { useFont } from \"@/components/ui/font-provider\"\n\nexport interface FontSwitcherProps {\n label?: string\n ariaLabel?: string\n roleLabels?: Partial>\n className?: string\n}\n\nconst DEFAULT_ROLE_LABELS: Record = {\n heading: \"Titres\",\n eyebrow: \"Eyebrow\",\n body: \"Texte\",\n}\n\nexport function FontSwitcher({\n label,\n ariaLabel = \"Font\",\n roleLabels,\n className,\n}: FontSwitcherProps) {\n const { isLocked, config, setRoleFont, allFonts } = useFont()\n\n if (isLocked) return null\n\n const labels: Record = {\n ...DEFAULT_ROLE_LABELS,\n ...roleLabels,\n }\n\n const roles = Object.keys(DEFAULT_ROLE_LABELS) as FontRole[]\n const categories = groupByCategory()\n\n return (\n \n \n \n {label && {label}}\n \n \n {roles.map((role) => (\n \n \n {labels[role]}\n \n {allFonts.find((f) => f.id === config[role])?.label ?? \"\"}\n \n \n \n {Object.entries(categories).map(([category, fonts]) => (\n \n \n {category}\n \n \n \n {fonts.map((font) => (\n setRoleFont(role, font.id)}\n >\n {font.label}\n {font.id === config[role] && (\n \n )}\n \n ))}\n \n \n ))}\n \n \n ))}\n \n \n )\n}\n", "target": "src/components/navigation/font-switcher.tsx" } ], "dependencies": [ "lucide-react" ], "registryDependencies": [ "cn", "dropdown-menu", "font-provider" ] }, { "name": "privacy-content", "type": "registry:lib", "title": "Privacy Policy Content", "description": "Reference legal content for the privacy policy page (Swiss nLPD/FADP), in fr/en/de/it. Source of truth for site dictionaries. Includes data controller, collected data, purposes, cookies/tracking, retention, rights.", "files": [ { "path": "registry/content/privacy-sections.ts", "type": "registry:lib", "content": "export interface PrivacySection {\n title: string\n content: string\n}\n\nexport type PrivacyLocale = \"fr\" | \"en\" | \"de\" | \"it\"\n\nexport const PRIVACY_UPDATED_AT = \"2026-07-31\"\n\n/**\n * Reference legal content for the privacy policy page (Swiss nLPD/FADP).\n * The Corner Factory SA reviews and updates this content once here;\n * site dictionaries are kept in sync from this source of truth.\n *\n * Placeholders per site (replace with client data):\n * - {businessName}, {address}, {email}\n */\nexport const PRIVACY_SECTIONS: Record = {\n fr: [\n {\n title: \"Responsable du traitement\",\n content:\n \"{businessName}\\n{address}\\nSuisse\\nEmail : {email}\",\n },\n {\n title: \"Données collectées\",\n content:\n \"Nous collectons uniquement les données nécessaires au bon fonctionnement du site et, avec votre consentement, des statistiques d'utilisation via Google Analytics (Google LLC, USA).\",\n },\n {\n title: \"Finalités\",\n content:\n \"Vos données sont utilisées pour faire fonctionner le site, répondre à vos demandes de contact et de rendez-vous, et améliorer nos services. Les finalités marketing ne sont poursuivies qu'avec un consentement séparé.\",\n },\n {\n title: \"Cookies et suivi\",\n content:\n \"Ce site utilise Google Analytics (GA4) pour mesurer l'audience. Ces cookies ne sont activés qu'après votre consentement explicite. Vous pouvez modifier vos préférences à tout moment via le lien « Gestion des cookies » dans le pied de page. Les données collectées par Google Analytics peuvent être transférées et traitées aux États-Unis.\",\n },\n {\n title: \"Durée de conservation\",\n content:\n \"Les données Google Analytics sont automatiquement supprimées par Google après 14 mois (durée de conservation par défaut de Google Analytics). Les données de contact (email, téléphone) sont conservées pendant la durée de notre relation commerciale et, à titre de registre de contacts, aussi longtemps que l'entreprise existe. Vous pouvez demander leur suppression à tout moment.\",\n },\n {\n title: \"Vos droits\",\n content:\n \"Vous disposez d'un droit d'accès, de modification, de suppression et de portabilité de vos données personnelles, ainsi que du droit de retirer votre consentement à tout moment. Pour exercer ces droits, veuillez nous contacter par email. Les données de navigation agrégées (Google Analytics) ne permettent pas d'identifier une personne individuelle.\",\n },\n ],\n en: [\n {\n title: \"Data controller\",\n content: \"{businessName}\\n{address}\\nSwitzerland\\nEmail: {email}\",\n },\n {\n title: \"Data collected\",\n content:\n \"We only collect data necessary for the proper functioning of the site and, with your consent, usage statistics via Google Analytics (Google LLC, USA).\",\n },\n {\n title: \"Purposes\",\n content:\n \"Your data is used to operate the website, respond to your contact and booking requests, and improve our services. Marketing purposes are only pursued with separate consent.\",\n },\n {\n title: \"Cookies and tracking\",\n content:\n \"This site uses Google Analytics (GA4) to measure audience. These cookies are only activated after your explicit consent. You can change your preferences at any time using the \\\"Cookie settings\\\" link in the footer. Data collected by Google Analytics may be transferred to and processed in the United States.\",\n },\n {\n title: \"Data retention\",\n content:\n \"Google Analytics data is automatically deleted by Google after 14 months (default retention period of Google Analytics). Contact data (email, phone) is kept for the duration of our business relationship and, as a contact register, for as long as the company exists. You can request its deletion at any time.\",\n },\n {\n title: \"Your rights\",\n content:\n \"You have the right to access, modify, delete, and port your personal data, as well as the right to withdraw your consent at any time. To exercise these rights, please contact us by email. Aggregated browsing data (Google Analytics) does not allow identifying an individual person.\",\n },\n ],\n de: [\n {\n title: \"Verantwortlicher\",\n content: \"{businessName}\\n{address}\\nSchweiz\\nE-Mail: {email}\",\n },\n {\n title: \"Datenerhebung\",\n content:\n \"Wir erheben nur Daten, die für den Betrieb der Website erforderlich sind, und mit Ihrer Einwilligung Nutzungsstatistiken über Google Analytics (Google LLC, USA).\",\n },\n {\n title: \"Zwecke\",\n content:\n \"Ihre Daten werden verwendet, um die Website zu betreiben, Ihre Kontakt- und Terminanfragen zu beantworten und unsere Dienstleistungen zu verbessern. Marketingzwecke werden nur mit separater Einwilligung verfolgt.\",\n },\n {\n title: \"Cookies und Tracking\",\n content:\n \"Diese Website verwendet Google Analytics (GA4) zur Reichweitenmessung. Diese Cookies werden nur nach Ihrer ausdrücklichen Einwilligung aktiviert. Sie können Ihre Einstellungen jederzeit über den Link « Cookie-Einstellungen » in der Fusszeile ändern. Die von Google Analytics erfassten Daten können in die USA übertragen und dort verarbeitet werden.\",\n },\n {\n title: \"Aufbewahrungsdauer\",\n content:\n \"Google-Analytics-Daten werden von Google nach 14 Monaten automatisch gelöscht (Standard-Aufbewahrungsfrist von Google Analytics). Kontaktdaten (E-Mail, Telefon) werden während der Dauer unserer Geschäftsbeziehung und als Kontaktregister so lange aufbewahrt, wie das Unternehmen besteht. Sie können deren Löschung jederzeit verlangen.\",\n },\n {\n title: \"Ihre Rechte\",\n content:\n \"Sie haben das Recht auf Auskunft, Berichtigung, Löschung und Übertragbarkeit Ihrer personenbezogenen Daten sowie das Recht, Ihre Einwilligung jederzeit zu widerrufen. Um diese Rechte auszuüben, kontaktieren Sie uns bitte per E-Mail. Aggregierte Navigationsdaten (Google Analytics) erlauben keine Identifizierung einer einzelnen Person.\",\n },\n ],\n it: [\n {\n title: \"Titolare del trattamento\",\n content: \"{businessName}\\n{address}\\nSvizzera\\nEmail: {email}\",\n },\n {\n title: \"Dati raccolti\",\n content:\n \"Raccogliamo solo i dati necessari al funzionamento del sito e, con il vostro consenso, statistiche di utilizzo tramite Google Analytics (Google LLC, USA).\",\n },\n {\n title: \"Finalità\",\n content:\n \"I vostri dati vengono utilizzati per gestire il sito, rispondere alle vostre richieste di contatto e prenotazione e migliorare i nostri servizi. Le finalità di marketing sono perseguite solo con un consenso separato.\",\n },\n {\n title: \"Cookie e tracciamento\",\n content:\n \"Questo sito utilizza Google Analytics (GA4) per misurare l'audience. Questi cookie vengono attivati solo dopo il vostro consenso esplicito. Potete modificare le vostre preferenze in qualsiasi momento tramite il link « Gestione cookie » nel footer. I dati raccolti da Google Analytics possono essere trasferiti ed elaborati negli Stati Uniti.\",\n },\n {\n title: \"Durata di conservazione\",\n content:\n \"I dati di Google Analytics vengono eliminati automaticamente da Google dopo 14 mesi (periodo di conservazione predefinito di Google Analytics). I dati di contatto (email, telefono) vengono conservati per la durata del nostro rapporto commerciale e, come registro di contatti, per tutto il tempo di esistenza dell'azienda. Potete richiederne la cancellazione in qualsiasi momento.\",\n },\n {\n title: \"I vostri diritti\",\n content:\n \"Avete diritto di accesso, modifica, cancellazione e portabilità dei vostri dati personali, nonché il diritto di revocare il vostro consenso in qualsiasi momento. Per esercitare questi diritti, contattateci via email. I dati di navigazione aggregati (Google Analytics) non consentono di identificare una singola persona.\",\n },\n ],\n}\n", "target": "src/content/privacy-sections.ts" } ] }, { "name": "footer-helpers", "type": "registry:lib", "title": "Footer Helpers", "description": "getFooterProps() — builds Footer props from site data with copyright placeholders and default The Corner attribution.", "files": [ { "path": "registry/lib/footer-helpers.ts", "type": "registry:lib", "content": "import type { FooterProps } from \"@/components/navigation/footer\"\r\n\r\nexport interface FooterOptions {\r\n /** Brand name shown in the footer and copyright line. */\r\n siteName: string\r\n /** Brand description (localized). */\r\n description: string\r\n /** Optional brand tagline (e.g. \"Chez Cathy · Grône\"). */\r\n tagline?: string\r\n /** Optional brand logo URL. */\r\n logo?: string\r\n /** Optional initial used as monogram fallback when no logo. */\r\n initial?: string\r\n /** Hide the monogram fallback when there is no logo. */\r\n hideMonogram?: boolean\r\n /** Brand name color — primary or foreground. */\r\n brandColor?: \"primary\" | \"foreground\"\r\n /** Navigation columns. */\r\n columns: FooterProps[\"columns\"]\r\n contact: {\r\n title?: string\r\n address?: string\r\n phone?: string\r\n phoneLabel?: string\r\n email?: string\r\n mapsUrl?: string\r\n hours?: string\r\n hoursLabel?: string\r\n }\r\n socials?: FooterProps[\"socials\"]\r\n /** Copyright text — {year}, {name}, {rights} placeholders are replaced. */\r\n copyright: string\r\n /** Localized \"All rights reserved\" text. */\r\n rights: string\r\n /** Legal links (mentions, privacy, terms). */\r\n legalLinks: { label: string; href: string }[]\r\n /** Default: The Corner Factory attribution. Pass null to hide. */\r\n attribution?: FooterProps[\"attribution\"] | null\r\n newsletter?: FooterProps[\"newsletter\"]\r\n manageCookiesEvent?: string\r\n variant?: \"default\" | \"dark\"\r\n accentColor?: \"accent\" | \"primary\" | \"secondary\" | \"foreground\"\r\n iconColor?: \"accent\" | \"primary\" | \"secondary\" | \"foreground\"\r\n /** Grouped color overrides (preferred over accentColor/iconColor). */\r\n colors?: FooterProps[\"colors\"]\r\n className?: string\r\n}\r\n\r\nconst CORNER_ATTRIBUTION = {\r\n text: \"Made by The Corner Factory\",\r\n href: \"https://the-corner.io/\",\r\n logo: \"https://assets.the-corner.io/logos/the_corner-icon.png\",\r\n}\r\n\r\n/**\r\n * Builds Footer props from site data.\r\n * Handles copyright placeholders ({year}, {name}, {rights}) and the\r\n * default The Corner Factory attribution.\r\n *\r\n * Footer color recipes (see FooterProps):\r\n * - Logo + dark footer: variant=\"dark\", colors: { headings: \"primary\", icons: \"primary\" }\r\n * - No logo + tagline: hideMonogram: true, colors: { brandName: \"primary\" },\r\n * className: \"bg-secondary/60\"\r\n * - Light default: no colors needed — headings default to text-primary.\r\n * Never default to text-secondary: in shadcn presets, --secondary is a\r\n * surface color nearly invisible on light backgrounds.\r\n */\r\nexport function getFooterProps(options: FooterOptions): FooterProps {\r\n const year = new Date().getFullYear()\r\n const {\r\n siteName,\r\n description,\r\n tagline,\r\n logo,\r\n initial,\r\n hideMonogram,\r\n brandColor,\r\n columns,\r\n contact,\r\n socials,\r\n copyright,\r\n rights,\r\n legalLinks,\r\n attribution = CORNER_ATTRIBUTION,\r\n newsletter,\r\n manageCookiesEvent = \"manage-cookies\",\r\n variant = \"default\",\r\n accentColor,\r\n iconColor,\r\n colors,\r\n className,\r\n } = options\r\n\r\n return {\r\n brand: { name: siteName, description, tagline, logo, initial, hideMonogram, brandColor },\r\n columns,\r\n contact,\r\n socials,\r\n legal: {\r\n copyright: copyright\r\n .replace(\"{year}\", String(year))\r\n .replace(\"{name}\", siteName)\r\n .replace(\"{rights}\", rights),\r\n links: legalLinks,\r\n },\r\n attribution: attribution ?? undefined,\r\n newsletter,\r\n manageCookiesEvent,\r\n variant,\r\n accentColor,\r\n iconColor,\r\n colors,\r\n className,\r\n }\r\n}\r\n", "target": "src/lib/footer-helpers.ts" } ], "registryDependencies": [ "footer" ] } ] }