From ad8c99dd0edf482d2054bd0156d0bc82cf063387 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Tue, 25 Aug 2026 14:06:55 +0100 Subject: [PATCH] feat(branding): section-based About page editor (Pages tab) + publish hardening - Decompose the tenant About page into 7 registered section components rendered via TemplateRenderer from a fixed layout (lib/templates/ about-page.ts); markup extracted verbatim so untouched tenants render pixel-identical. Legacy about-content.tsx removed. - pageContent.about v2: sparse per-section configs {version:2, sections[]}; legacy flat keys auto-mapped on load; save REPLACES the about subtree so blanked fields genuinely reset to defaults. - Store Editor: new Pages tab (schema-driven section forms, show/hide, per-section reset, per-section colour overrides), Home/About live-preview toggle, ?page=about on iframe + external preview (tenant previews). - Publish fixes: pre-save S3 snapshot to {s3Path}/backups/ (keep 10); tenant_branding write-through so emails/OG/login follow a rebrand; revalidatePath on the store subtree incl. the custom-domain cd-hash segment (publishes were up to 60s stale); footer /faq -> /support. - Security: shared signSectionAssets enforces PRD-206 tenant scope on every sign (also fixes pre-existing unscoped absolute-key signing on the store home); branding-backup fails closed on non-tenant s3Path; AboutCta href scheme guard; tenant_branding sync accepts own-tenant upload keys only. - Tests: about-page contract suite (resolve/serialize/round-trip, legacy mapping, visibility rules); initial-data aboutSections cases. --- .../app/api/tenant-admin/branding/route.ts | 91 +++ .../app/store/[slug]/about/about-content.tsx | 616 ------------------ nextjs_space/app/store/[slug]/about/page.tsx | 55 +- nextjs_space/app/store/[slug]/page.tsx | 56 +- .../app/store/preview/[templateSlug]/page.tsx | 21 +- .../branding/branding-form-initial-data.ts | 52 +- .../tenant-admin/branding/branding-form.tsx | 122 +++- .../app/tenant-admin/branding/page.tsx | 40 ++ .../tenant-admin/branding/tabs/brand-tab.tsx | 70 +- .../tenant-admin/branding/tabs/pages-tab.tsx | 320 +++++++++ .../app/tenant-admin/branding/tabs/types.ts | 9 +- nextjs_space/components/footer.tsx | 2 +- .../components/sections/about/AboutCta.tsx | 71 ++ .../sections/about/AboutFacilities.tsx | 139 ++++ .../components/sections/about/AboutHero.tsx | 75 +++ .../sections/about/AboutMission.tsx | 125 ++++ .../components/sections/about/AboutStats.tsx | 63 ++ .../sections/about/AboutTimeline.tsx | 113 ++++ .../components/sections/about/AboutValues.tsx | 108 +++ .../components/sections/about/motion.ts | 15 + nextjs_space/lib/icon-registry.ts | 6 +- nextjs_space/lib/templates/about-page.ts | 281 ++++++++ nextjs_space/lib/templates/branding-backup.ts | 76 +++ nextjs_space/lib/templates/font-catalog.ts | 51 ++ .../lib/templates/section-registry.ts | 16 + .../lib/templates/section-schema-types.ts | 5 +- .../lib/templates/section-schemas-data.ts | 91 +++ .../lib/templates/sign-layout-assets.ts | 90 +++ .../tests/unit/about-page-layout.test.ts | 206 ++++++ .../unit/branding-form-initial-data.test.ts | 77 +++ 30 files changed, 2244 insertions(+), 818 deletions(-) delete mode 100644 nextjs_space/app/store/[slug]/about/about-content.tsx create mode 100644 nextjs_space/app/tenant-admin/branding/tabs/pages-tab.tsx create mode 100644 nextjs_space/components/sections/about/AboutCta.tsx create mode 100644 nextjs_space/components/sections/about/AboutFacilities.tsx create mode 100644 nextjs_space/components/sections/about/AboutHero.tsx create mode 100644 nextjs_space/components/sections/about/AboutMission.tsx create mode 100644 nextjs_space/components/sections/about/AboutStats.tsx create mode 100644 nextjs_space/components/sections/about/AboutTimeline.tsx create mode 100644 nextjs_space/components/sections/about/AboutValues.tsx create mode 100644 nextjs_space/components/sections/about/motion.ts create mode 100644 nextjs_space/lib/templates/about-page.ts create mode 100644 nextjs_space/lib/templates/branding-backup.ts create mode 100644 nextjs_space/lib/templates/font-catalog.ts create mode 100644 nextjs_space/lib/templates/sign-layout-assets.ts create mode 100644 nextjs_space/tests/unit/about-page-layout.test.ts diff --git a/nextjs_space/app/api/tenant-admin/branding/route.ts b/nextjs_space/app/api/tenant-admin/branding/route.ts index 621f6338..cec5d6ca 100644 --- a/nextjs_space/app/api/tenant-admin/branding/route.ts +++ b/nextjs_space/app/api/tenant-admin/branding/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; import { Prisma } from "@prisma/client"; import { apiError, apiValidationError } from "@/lib/api-error"; import { withTenantAuth } from "@/lib/api-auth"; @@ -9,6 +10,10 @@ import { TenantSettings } from "@/lib/types"; import { parseTenantSettings } from "@/lib/tenant/tenant-settings"; import { deepMerge } from "@/lib/utils"; import { stripSignedUrls } from "@/lib/templates/strip-signed-urls"; +import { isAboutContentV2 } from "@/lib/templates/about-page"; +import { writeBrandingSnapshot } from "@/lib/templates/branding-backup"; +import { fontIdToName } from "@/lib/templates/font-catalog"; +import { customDomainSlugForHost } from "@/lib/custom-domain-rewrite"; import { hexToHsl } from "@/lib/color-utils"; import { logger } from "@/lib/logger"; @@ -286,6 +291,11 @@ export const PUT = withTenantAuth(async (req, { tenantId }) => { s3Path: currentTemplate?.s3Path || "MISSING", }); + // Pre-save snapshot source: the layout branch below fills this with the + // layout.json it is about to overwrite (it reads it anyway). + let snapshotLayout: any = null; + const preSaveS3Path = currentTemplate?.s3Path?.replace(/\/+$/, '') || null; + if (hasLayoutSections || hasSectionConfigs || hasSectionColorOverrides || hasNavFooterConfig) { // Read existing layout from S3 — tenant's own path, no fallback const existingS3Path = currentTemplate?.s3Path?.replace(/\/+$/, '') || null; @@ -297,6 +307,7 @@ export const PUT = withTenantAuth(async (req, { tenantId }) => { baseLayout = {}; } } + snapshotLayout = baseLayout; // Use incoming sections if provided, otherwise keep existing sections from S3 const sourceSections = hasLayoutSections @@ -397,11 +408,39 @@ export const PUT = withTenantAuth(async (req, { tenantId }) => { if (settings.pageContent) { const currentPageContent = (currentTemplate?.pageContent as any) || {}; updateData.pageContent = deepMerge(currentPageContent, settings.pageContent); + // pageContent.about (v2) is a whole-page versioned payload: REPLACE it + // rather than deep-merge so blanked fields ('' = fall back to default) + // and superseded legacy flat keys don't linger in the stored blob. + const incomingAbout = (settings.pageContent as any)?.about; + if (isAboutContentV2(incomingAbout)) { + updateData.pageContent = { ...updateData.pageContent, about: incomingAbout }; + } } logger.info("[branding] Saving designSystem", { designSystemKeys: Object.keys(newDesignSystem), colorKeys: Object.keys(newDesignSystem.colors || {}) }); logger.info("[branding] Saving pageContent", { pageContentKeys: Object.keys(updateData.pageContent || {}) }); + // Rollback safety net: snapshot the state this save is about to + // overwrite (publishing is immediate and destructive — no draft/undo). + // Best-effort; never blocks the save. Skipped when the template has no + // pre-existing S3 path (first save — nothing meaningful to back up). + if (preSaveS3Path) { + await writeBrandingSnapshot(preSaveS3Path, tenant.id, { + savedAt: new Date().toISOString(), + tenantId: tenant.id, + templateId: activeTemplateId, + designSystem: currentTemplate?.designSystem ?? null, + pageContent: currentTemplate?.pageContent ?? null, + navigation: currentTemplate?.navigation ?? null, + footer: currentTemplate?.footer ?? null, + customCss: currentTemplate?.customCss ?? null, + logoUrl: currentTemplate?.logoUrl ?? null, + heroImageUrl: currentTemplate?.heroImageUrl ?? null, + faviconUrl: currentTemplate?.faviconUrl ?? null, + layoutJson: snapshotLayout, + }); + } + // Update TenantTemplate — AC-C1: structurally strip any signed URL // (designSystem/pageContent/customCss are all walked, not just the // fields a per-shape list happened to anticipate). @@ -431,6 +470,58 @@ export const PUT = withTenantAuth(async (req, { tenantId }) => { }); } + // Keep tenant_branding in step with the published storefront branding. + // Emails, OG images and the store login page read tenant_branding, which + // was previously written only at onboarding — so a rebrand silently left + // stale colours/logo in those surfaces. Only fields present in this save + // are written (colours arrive only when dirty; logo/favicon only when a + // new file was uploaded). logoUrl/faviconUrl store S3 keys, matching the + // existing tenant_branding contract (see lib/email/email-shell.ts). + const isHexColor = (v: unknown): v is string => + typeof v === "string" && /^#[0-9a-fA-F]{3,8}$/.test(v.trim()); + const brandingSync: Record = {}; + if (isHexColor(settings.primaryColor)) brandingSync.primaryColor = settings.primaryColor.trim(); + if (isHexColor(settings.secondaryColor)) brandingSync.secondaryColor = settings.secondaryColor.trim(); + if (isHexColor(settings.accentColor)) brandingSync.accentColor = settings.accentColor.trim(); + const brandingFontName = fontIdToName(settings.fontFamily); + if (brandingFontName) brandingSync.fontFamily = brandingFontName; + // Only own-tenant upload keys reach tenant_branding: with no file attached, + // settings.logoPath is client-supplied text, and this row feeds public + // surfaces (OG images, emails). Uploads in this request always match. + const isOwnUploadKey = (v: unknown): v is string => + typeof v === "string" && v.startsWith(`tenants/${tenantId}/`); + if (isOwnUploadKey(settings.logoPath)) brandingSync.logoUrl = settings.logoPath; + if (isOwnUploadKey(settings.faviconPath)) brandingSync.faviconUrl = settings.faviconPath; + if (Object.keys(brandingSync).length > 0) { + await prisma.tenant_branding.upsert({ + where: { tenantId: tenant.id }, + update: { ...brandingSync, updatedAt: new Date() }, + create: { + id: crypto.randomUUID(), + tenantId: tenant.id, + ...brandingSync, + updatedAt: new Date(), + }, + }); + } + + // On-demand ISR purge: without this a publish stayed invisible for up to + // 60s (`revalidate = 60` with no revalidatePath anywhere). "layout" scope + // invalidates every store page under the slug — About, home, support, … + // Custom domains cache under their own host-scoped cd- segment + // (PRD-212), so purge that too. + try { + revalidatePath(`/store/${tenant.subdomain}`, "layout"); + if (tenant.customDomain) { + revalidatePath(`/store/${customDomainSlugForHost(tenant.customDomain)}`, "layout"); + } + } catch (revalidateError) { + logger.info("[branding] revalidatePath failed (non-fatal)", { + tenantId: tenant.id, + error: revalidateError instanceof Error ? revalidateError.message : String(revalidateError), + }); + } + return NextResponse.json({ success: true, message: "Branding updated successfully", diff --git a/nextjs_space/app/store/[slug]/about/about-content.tsx b/nextjs_space/app/store/[slug]/about/about-content.tsx deleted file mode 100644 index 6132f533..00000000 --- a/nextjs_space/app/store/[slug]/about/about-content.tsx +++ /dev/null @@ -1,616 +0,0 @@ -"use client"; - -import Link from "next/link"; -import Image from "next/image"; -import { motion } from "framer-motion"; -import { - Target, - Heart, - Globe, - Shield, - CheckCircle2, - ArrowRight, -} from "lucide-react"; - -const fadeInUp = { - hidden: { opacity: 0, y: 30 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.6 } }, -}; - -const staggerContainer = { - hidden: { opacity: 0 }, - visible: { opacity: 1, transition: { staggerChildren: 0.12 } }, -}; - -const defaultValues = [ - { - icon: "Target", - title: "Excellence", - desc: "Uncompromising quality in every product and process", - }, - { - icon: "Heart", - title: "Patient-Focused", - desc: "Putting patient needs and wellbeing at the heart of everything we do", - }, - { - icon: "Globe", - title: "Global Reach", - desc: "Serving patients across continents with consistent standards", - }, - { - icon: "Shield", - title: "Integrity", - desc: "Operating with transparency, compliance, and ethical responsibility", - }, -]; - -const defaultStats = [ - { value: "10,000+", label: "Patients Served" }, - { value: "50+", label: "Products Available" }, - { value: "100%", label: "Quality Certified" }, - { value: "24/7", label: "Patient Support" }, -]; - -const defaultFacilities = [ - { - title: "Cultivation & Processing", - description: - "State-of-the-art cultivation and processing facility meeting the highest international quality standards.", - features: [ - "GMP-certified production", - "Advanced indoor growing systems", - "Quality control laboratories", - "Sustainable practices", - ], - }, - { - title: "Distribution & Fulfilment", - description: - "Efficient distribution network ensuring timely, secure delivery of medical cannabis products to patients.", - features: [ - "Temperature-controlled storage", - "Tracked delivery systems", - "Regulatory compliance", - "Discreet packaging", - ], - }, -]; - -const defaultTimeline = [ - { year: "Founded", description: "Established with a mission to improve patient access to medical cannabis" }, - { year: "Licensed", description: "Obtained all regulatory approvals and licensing for medical cannabis operations" }, - { year: "Expanded", description: "Grew our product range and extended our services to more patients" }, - { year: "Today", description: "Continuing to innovate and improve patient outcomes through quality cannabis medicine" }, -]; - -const iconMap: Record = { - Target, - Heart, - Globe, - Shield, -}; - -interface AboutContentProps { - basePath: string; - businessName: string; - pageContent?: any; -} - -export default function AboutContent({ - basePath, - businessName, - pageContent, -}: AboutContentProps) { - const heroTitle = pageContent?.heroTitle || `About ${businessName}`; - const heroSubtitle = - pageContent?.heroSubtitle || - "Setting new standards in medical cannabis excellence"; - const missionImage = pageContent?.missionImage || null; - const missionTitle = pageContent?.missionTitle || "Our Mission"; - const missionParagraphs = pageContent?.missionParagraphs || [ - `${businessName} was founded with a vision to improve patient access to high-quality medical cannabis. We believe every patient deserves safe, effective, and consistent medication backed by rigorous science.`, - "Our team of medical professionals, researchers, and industry experts work together to ensure our products meet the highest pharmaceutical standards. From seed to patient, we maintain complete quality control.", - "We are committed to advancing the science of medical cannabis through ongoing research, education, and collaboration with healthcare providers worldwide.", - ]; - const stats = pageContent?.stats || defaultStats; - const values = pageContent?.values || defaultValues; - const facilities = pageContent?.facilities || defaultFacilities; - const timeline = pageContent?.timeline || defaultTimeline; - const ctaTitle = pageContent?.ctaTitle || "Ready to Learn More?"; - const ctaSubtitle = - pageContent?.ctaSubtitle || - "Get in touch with our team to discuss how we can support your medical cannabis needs."; - - return ( - <> - {/* Hero Section */} -
-
- - - - Our Story - - - - {heroTitle} - - - {heroSubtitle} - - -
-
- - {/* Mission / Story */} -
-
- - -
- -
-

- {missionTitle} -

-
- -
-
- {missionParagraphs.map((text: string, i: number) => ( - - {text} - - ))} -
- - {missionImage && ( - - {missionTitle} - - )} -
-
-
-
- - {/* Stats */} -
-
- - {stats.map( - (stat: { value: string; label: string }, i: number) => ( - -

- {stat.value} -

-

- {stat.label} -

-
- ) - )} -
-
-
- - {/* Values */} -
-
- - Our Values - - - {values.map( - ( - item: { icon: string; title: string; desc: string }, - i: number - ) => { - const IconComp = iconMap[item.icon] || Target; - return ( - -
- -
-

- {item.title} -

-

- {item.desc} -

-
- ); - } - )} -
-
-
- - {/* Facilities */} -
-
- - - Our Facilities - - - World-class operations meeting the highest international standards. - - -
- {facilities.map( - ( - facility: { - title: string; - description: string; - features: string[]; - image?: string; - }, - i: number - ) => ( - - {facility.image && ( -
- {facility.title} -
-
- )} -
-

- {facility.title} -

-

- {facility.description} -

-
    - {facility.features.map( - (feature: string, fi: number) => ( -
  • - - - {feature} - -
  • - ) - )} -
-
- - ) - )} -
-
-
-
- - {/* Timeline (only if data exists) */} - {timeline && timeline.length > 0 && ( -
-
- - - Our Journey - - -
- {/* Center line */} -
- -
- {timeline.map( - ( - item: { year: string; description: string }, - i: number - ) => ( - -
- - {item.year} - -

- {item.description} -

-
- - {/* Center dot */} -
-
-
- -
- - ) - )} -
-
-
-
-
- )} - - {/* CTA Section */} -
-
- - - {ctaTitle} - - - {ctaSubtitle} - - - - Contact Us - - - -
-
- - ); -} diff --git a/nextjs_space/app/store/[slug]/about/page.tsx b/nextjs_space/app/store/[slug]/about/page.tsx index 71cecbd5..78ef2882 100644 --- a/nextjs_space/app/store/[slug]/about/page.tsx +++ b/nextjs_space/app/store/[slug]/about/page.tsx @@ -3,18 +3,28 @@ import { notFound } from "next/navigation"; import { getCurrentTenant, getTenantWithTemplate } from "@/lib/tenant/tenant"; import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; import { generateStorePageMetadata } from "@/lib/seo/generate-page-metadata"; -import AboutContent from "./about-content"; +import { TemplateRenderer } from "@/components/template-renderer"; +import { buildAboutLayout } from "@/lib/templates/about-page"; +import { signSectionAssets } from "@/lib/templates/sign-layout-assets"; + +// Match the store home's ISR window so a branding publish (which calls +// revalidatePath on the store subtree) refreshes this page immediately. +export const revalidate = 60; /** SEO US-002 — tenants.pageSeo.about, shared with the page's own tenant fetch. */ export function generateMetadata(): Promise { return generateStorePageMetadata("about"); } -export default async function AboutPage({ - params, -}: { - params: { slug: string }; -}) { +/** + * Tenant About page — a fixed section layout (lib/templates/about-page.ts) + * rendered through the same TemplateRenderer as the store home. Section + * configs come from `tenant_templates.pageContent.about` (sparse overlays + * edited in the branding Store Editor's Pages tab); an untouched tenant gets + * the stock page, byte-for-byte the markup of the legacy about-content.tsx. + * Nav/footer come from the store layout, hence renderChrome={false}. + */ +export default async function AboutPage() { const tenant = await getCurrentTenant(); if (!tenant) { @@ -22,9 +32,30 @@ export default async function AboutPage({ } const basePath = getTenantBasePath(tenant.subdomain); + // Cached — shared with layout.tsx, no duplicate DB hit const tenantWithTemplate = await getTenantWithTemplate(tenant.id); - const pageContent = - (tenantWithTemplate?.activeTenantTemplate?.pageContent as any)?.about; + + if (!tenantWithTemplate) { + notFound(); + } + + const activeTemplate = tenantWithTemplate.activeTenantTemplate; + const pageContent = (activeTemplate?.pageContent as any) || {}; + + const layout = buildAboutLayout(pageContent.about); + + const tenantS3Path = activeTemplate?.s3Path?.replace(/\/+$/, "") || null; + await signSectionAssets(layout.sections, tenantS3Path, tenant.id); + + const sectionProps = { + tenant: tenantWithTemplate, + consultationUrl: `${basePath}/consultation`, + productsUrl: `${basePath}/products`, + contactUrl: `${basePath}/contact`, + aboutUrl: `${basePath}/about`, + designSystem: activeTemplate?.designSystem, + pageContent, + }; return (
-
diff --git a/nextjs_space/app/store/[slug]/page.tsx b/nextjs_space/app/store/[slug]/page.tsx index 49841e43..efc0e411 100644 --- a/nextjs_space/app/store/[slug]/page.tsx +++ b/nextjs_space/app/store/[slug]/page.tsx @@ -21,7 +21,7 @@ import { TEMPLATE_COMPONENTS } from "@/lib/templates/template-registry"; // Import section-based renderer (data-driven templates) import { TemplateRenderer } from "@/components/template-renderer"; -import { SECTION_ASSET_KEYS, type TemplateLayout } from "@/lib/types/template-layout"; +import { signSectionAssets } from "@/lib/templates/sign-layout-assets"; // Import existing homepage components (fallback) import { HeroSection } from "@/components/home/hero-section"; @@ -220,58 +220,10 @@ async function StoreHomeContent({ // Sign section-level asset URLs in layout.json configs // Assets are at the tenant's own S3 path — no fallback needed + // (shared with the About page — lib/templates/sign-layout-assets.ts; + // tenant-scoped: out-of-scope absolute keys are skipped, not signed) if (layout?.sections && tenantS3Path) { - const assetKeys = SECTION_ASSET_KEYS; - - function signAssetUrl(val: string, contentTypeHint?: string): Promise { - const isAbsoluteKey = val.startsWith('development/') || val.startsWith('tenants/') || val.startsWith('templates/'); - if (isAbsoluteKey) { - return getFileUrl(val, contentTypeHint); - } - return getFileUrl(`${tenantS3Path}/${val}`, contentTypeHint); - } - - // Collect all signing tasks, then execute in parallel - const signingTasks: Array<{ target: any; key: string; promise: Promise }> = []; - for (const section of layout.sections) { - for (const key of assetKeys) { - const val = section.config?.[key]; - if (val && typeof val === 'string' && !val.startsWith('http') && !val.startsWith('/')) { - // For videoUrl keys without a file extension, hint video/mp4 content type - const hint = key === 'videoUrl' && !/\.\w+$/.test(val) ? 'video/mp4' : undefined; - signingTasks.push({ target: section.config, key, promise: signAssetUrl(val, hint) }); - } - } - // Sign asset URLs inside nested arrays (e.g. categories[].imageUrl, logos[].src) - if (section.config) { - for (const arrKey of Object.keys(section.config)) { - if (Array.isArray(section.config[arrKey])) { - for (let idx = 0; idx < section.config[arrKey].length; idx++) { - const item = section.config[arrKey][idx]; - // Handle flat string arrays (e.g. SocialProof avatars[]) - if (typeof item === 'string' && !item.startsWith('http') && !item.startsWith('/') && (item.includes('/') || item.match(/\.(png|jpg|jpeg|webp|svg|gif)$/i))) { - signingTasks.push({ target: section.config[arrKey], key: String(idx), promise: signAssetUrl(item) }); - continue; - } - if (!item || typeof item !== 'object') continue; - for (const itemKey of Object.keys(item)) { - const v = (item as any)[itemKey]; - if (v && typeof v === 'string' && !v.startsWith('http') && !v.startsWith('/') && (v.includes('/') || v.match(/\.(png|jpg|jpeg|webp|svg|gif)$/i))) { - signingTasks.push({ target: item, key: itemKey, promise: signAssetUrl(v) }); - } - } - } - } - } - } - } - - const results = await Promise.allSettled(signingTasks.map(t => t.promise)); - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - (signingTasks[i].target as any)[signingTasks[i].key] = result.value; - } - }); + await signSectionAssets(layout.sections, tenantS3Path, tenant.id); } if (layout) { diff --git a/nextjs_space/app/store/preview/[templateSlug]/page.tsx b/nextjs_space/app/store/preview/[templateSlug]/page.tsx index fe814bd0..f256b58d 100644 --- a/nextjs_space/app/store/preview/[templateSlug]/page.tsx +++ b/nextjs_space/app/store/preview/[templateSlug]/page.tsx @@ -7,6 +7,7 @@ import { getJsonFromS3, getTextFromS3, getFileUrl } from "@/lib/storage/s3"; import { isKeyInTenantScope } from "@/lib/storage/s3-tenant-guard"; import { getBucketConfig } from "@/lib/storage/aws-config"; import { SECTION_ASSET_KEYS, type TemplateLayout } from "@/lib/types/template-layout"; +import { buildAboutLayout } from "@/lib/templates/about-page"; import { Tenant } from "@/types/client"; import { TenantThemeProvider } from "@/components/tenant-theme-provider"; import { prisma } from "@/lib/db"; @@ -184,12 +185,13 @@ export default async function TemplatePreviewPage({ searchParams, }: { params: Promise<{ templateSlug: string }>; - searchParams: Promise<{ embed?: string; tenantTemplateId?: string }>; + searchParams: Promise<{ embed?: string; tenantTemplateId?: string; page?: string }>; }) { const { templateSlug } = await params; const resolvedSearchParams = await searchParams; const isEmbed = resolvedSearchParams.embed === "true"; const tenantTemplateId = resolvedSearchParams.tenantTemplateId; + const previewPage = resolvedSearchParams.page; // ─── Tenant-specific preview (tenantTemplateId provided) ─── // Loads the tenant's CUSTOMIZED template from DB + their S3 path. No fallback. @@ -297,6 +299,21 @@ export default async function TemplatePreviewPage({ valueProps: defaults?.valueProps || [], }; + // About-page preview (?page=about — the Store Editor's Home/About toggle). + // Fixed section layout from pageContent.about wearing the home layout's + // nav/footer chrome; assets signed through the same scope-guarded path. + const effectiveLayout: TemplateLayout = previewPage === "about" + ? buildAboutLayout((tenantTemplate.pageContent as any)?.about, { + navigation: layout.navigation, + navigationConfig: (layout as any).navigationConfig, + footer: layout.footer, + footerConfig: (layout as any).footerConfig, + }) + : layout; + if (previewPage === "about") { + await signLayoutAssets(effectiveLayout, s3Prefix); + } + return ( }
({ + id: s.id, + type: s.type, + visible: s.visible !== false, + config: { ...(s.config || {}) }, + })); + for (const s of aboutEntries) { + if (s.colorOverrides && Object.keys(s.colorOverrides).length > 0) { + initialColorOverrides[s.id] = { ...(s.colorOverrides as Record) }; + } + } + return { sectionConfigs: initialSectionConfigs, layoutSections: initialLayoutSections, @@ -238,36 +258,8 @@ export function buildInitialFormData( homeHeroOverlayStyle: templateContent.home?.heroOverlayStyle || settingsContent.home?.heroOverlayStyle || "gradient-dark", homeHeroOverlayOpacity: templateContent.home?.heroOverlayOpacity ?? settingsContent.home?.heroOverlayOpacity ?? 70, - // About — read the keys AboutContent actually uses, with legacy fallbacks - aboutHeroTitle: - templateContent.about?.heroTitle - || templateContent.about?.title - || templateContent.aboutTitle - || settingsContent.about?.heroTitle - || settingsContent.about?.title - || "", - aboutHeroSubtitle: - templateContent.about?.heroSubtitle - || settingsContent.about?.heroSubtitle - || "", - aboutMissionTitle: - templateContent.about?.missionTitle - || settingsContent.about?.missionTitle - || "Our Mission", - aboutMissionParagraphs: (() => { - const paras = - templateContent.about?.missionParagraphs - || settingsContent.about?.missionParagraphs; - if (Array.isArray(paras)) return paras.join("\n\n"); - // Fallback: legacy free-form content field - return ( - templateContent.about?.content - || templateContent.aboutContent - || templateContent.aboutMission - || settingsContent.about?.content - || "" - ); - })(), + // About — fixed section list with sparse configs (see block above) + aboutSections: initialAboutSections, contactTitle: templateContent.contact?.title || settingsContent.contact?.title || "Get in Touch", contactDescription: templateContent.contact?.description || settingsContent.contact?.description || "Have questions? We are here to help.", diff --git a/nextjs_space/app/tenant-admin/branding/branding-form.tsx b/nextjs_space/app/tenant-admin/branding/branding-form.tsx index 50d3f17d..929dc151 100644 --- a/nextjs_space/app/tenant-admin/branding/branding-form.tsx +++ b/nextjs_space/app/tenant-admin/branding/branding-form.tsx @@ -7,6 +7,7 @@ import { toast } from "@/components/ui/sonner"; import { Layout, FileText, + Files, Eye, Store, Monitor, @@ -20,6 +21,7 @@ import { tenant_templates } from "@prisma/client"; import { hexToHsl } from "@/lib/color-utils"; import { TemplateRenderer } from "@/components/template-renderer"; import { TenantThemeProvider } from "@/components/tenant-theme-provider"; +import { aboutSectionsToContentV2, buildAboutLayout } from "@/lib/templates/about-page"; // Tab components import { BrandTab } from "./tabs/brand-tab"; @@ -27,6 +29,7 @@ import { LayoutTab } from "./tabs/layout-tab"; import { DesignTab } from "./tabs/design-tab"; import { ColoursTab } from "./tabs/colours-tab"; import { ContentTab } from "./tabs/content-tab"; +import { PagesTab } from "./tabs/pages-tab"; import { TypeTab } from "./tabs/type-tab"; // EducationTab and AdvancedTab imports removed while those tabs are hidden — // restore them together with the TabsTrigger/TabsContent blocks when re-enabling. @@ -62,6 +65,7 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ const [showPreview, setShowPreview] = useState(false); const [dirtyColors, setDirtyColors] = useState>(new Set()); const [previewDevice, setPreviewDevice] = useState<"desktop" | "tablet" | "mobile">("desktop"); + const [previewPage, setPreviewPage] = useState<"home" | "about">("home"); /** Scrollable container that wraps the inline desktop preview. Used by * scrollPreviewToSection to scroll/pulse a section when the user selects @@ -102,6 +106,16 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ } }, [previewDevice]); + /** Opening a section in the Content (home) or Pages (about) tab switches + * the preview to that page, then scrolls once the layout has rendered. */ + const makeSectionSelectHandler = useCallback( + (page: "home" | "about") => (sectionId: string) => { + setPreviewPage(page); + setTimeout(() => scrollPreviewToSection(sectionId), 120); + }, + [scrollPreviewToSection], + ); + const [formData, setFormData] = useState(() => buildInitialFormData(tenant, activeTemplate), ); @@ -150,15 +164,10 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ heroOverlayStyle: formData.homeHeroOverlayStyle, heroOverlayOpacity: formData.homeHeroOverlayOpacity, }, - about: { - heroTitle: formData.aboutHeroTitle, - heroSubtitle: formData.aboutHeroSubtitle, - missionTitle: formData.aboutMissionTitle, - missionParagraphs: formData.aboutMissionParagraphs - .split(/\n{2,}/) - .map((p) => p.trim()) - .filter(Boolean), - }, + about: aboutSectionsToContentV2( + formData.aboutSections, + formData.sectionColorOverrides, + ), contact: { title: formData.contactTitle, description: formData.contactDescription, @@ -180,10 +189,7 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ homeHeroHeight: undefined, homeHeroOverlayStyle: undefined, homeHeroOverlayOpacity: undefined, - aboutHeroTitle: undefined, - aboutHeroSubtitle: undefined, - aboutMissionTitle: undefined, - aboutMissionParagraphs: undefined, + aboutSections: undefined, contactTitle: undefined, contactDescription: undefined, contactEmail: undefined, @@ -269,15 +275,10 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ heroOverlayStyle: formData.homeHeroOverlayStyle, heroOverlayOpacity: formData.homeHeroOverlayOpacity, }, - about: { - heroTitle: formData.aboutHeroTitle, - heroSubtitle: formData.aboutHeroSubtitle, - missionTitle: formData.aboutMissionTitle, - missionParagraphs: formData.aboutMissionParagraphs - .split(/\n{2,}/) - .map((p) => p.trim()) - .filter(Boolean), - }, + about: aboutSectionsToContentV2( + formData.aboutSections, + formData.sectionColorOverrides, + ), contact: { title: formData.contactTitle, description: formData.contactDescription, @@ -358,6 +359,27 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ } : null; + // About page preview — fixed section layout driven by the Pages tab state, + // wearing the same nav/footer chrome as the home preview. + const liveAboutLayout = buildAboutLayout(livePageContent.about, { + navigation: formData.navigationStyle, + navigationConfig: { + ...formData.navigationConfig, + ...(Object.keys(formData.navColorOverrides).length > 0 + ? { colorOverrides: formData.navColorOverrides } + : {}), + }, + footer: formData.footerStyle, + footerConfig: { + ...formData.footerConfig, + ...(Object.keys(formData.footerColorOverrides).length > 0 + ? { colorOverrides: formData.footerColorOverrides } + : {}), + }, + }); + + const activePreviewLayout = previewPage === "about" ? liveAboutLayout : liveLayout; + // --- Render --- return (
@@ -393,9 +415,10 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ // preview route would 404. Falling through with just the slug loads // the base template from templates/{slug} in S3. const baseSlug = (activeTemplate as any)?.templates?.slug || tenant.subdomain; + const pageSuffix = previewPage === "about" ? "&page=about" : ""; const previewHref = previewMode === "marketplace" || !activeTemplate?.id ? `/store/preview/${baseSlug}` - : `/store/preview/${baseSlug}?tenantTemplateId=${activeTemplate.id}`; + : `/store/preview/${baseSlug}?tenantTemplateId=${activeTemplate.id}${pageSuffix}`; return ( - + Brand @@ -439,6 +462,10 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ Content + + + Pages + {/* Education and Advanced tabs hidden for now — keeping it simple for early users. Re-enable by restoring the TabsTrigger and TabsContent blocks below. */} @@ -476,7 +503,17 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ + + + {/* PAGES — per-page section content for the fixed storefront + pages (About). Same schema-driven editing as Content. */} + + @@ -511,7 +548,30 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ
Live Preview — {formData.businessName || tenant.businessName}
-
+
+ {/* Page toggle — which storefront page the preview shows */} +
+ {([ + { id: "home" as const, label: "Home" }, + { id: "about" as const, label: "About" }, + ]).map((p) => ( + + ))} +
+
{([ { id: "desktop" as const, icon: Monitor, label: "Desktop" }, { id: "tablet" as const, icon: Tablet, label: "Tablet" }, @@ -532,6 +592,7 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ
@@ -542,13 +603,13 @@ export default function BrandingForm({ tenant, activeTemplate, apiEndpoint, publ className="w-full h-full pt-10 overflow-y-auto overflow-x-hidden preview-scrollbar bg-bs-canvas relative" style={{ transform: "scale(1)" }} > - {liveLayout ? ( + {activePreviewLayout ? ( ({ + type: s.type, + id: s.id, + config: s.config, + visible: s.visible, + ...(s.colorOverrides ? { colorOverrides: s.colorOverrides } : {}), + })); + await signSectionAssets( + aboutSections, + activeTemplate.s3Path?.replace(/\/+$/, '') || null, + tenant.id, + ); + (activeTemplate as any).pageContent = { + ...pageContent, + about: { + version: ABOUT_PAGE_CONTENT_VERSION, + sections: aboutSections.map((s) => ({ + id: s.id!, + type: s.type, + visible: s.visible, + config: s.config, + ...(s.colorOverrides ? { colorOverrides: s.colorOverrides } : {}), + })), + }, + }; + } catch (e) { + console.error("[BrandingPage] Failed to sign About page assets", e); + } } return ( diff --git a/nextjs_space/app/tenant-admin/branding/tabs/brand-tab.tsx b/nextjs_space/app/tenant-admin/branding/tabs/brand-tab.tsx index ef5823cb..6ccc0ce4 100644 --- a/nextjs_space/app/tenant-admin/branding/tabs/brand-tab.tsx +++ b/nextjs_space/app/tenant-admin/branding/tabs/brand-tab.tsx @@ -435,81 +435,17 @@ export function BrandTab({ {/* === PAGE CONTENT === */} + {/* About page content moved to the Pages tab (schema-driven sections). */}

Page Content

- Edit the text shown on your About and Contact pages + Edit the text shown on your Contact page — the About page has its + own Pages tab

- {/* --- About Page --- */} -
-

About Page

- -
- - - setFormData((prev) => ({ ...prev, aboutHeroTitle: e.target.value })) - } - placeholder={`About ${formData.businessName || "Us"}`} - className="mt-1" - /> -
- -
- -