From 084de6c05b62d5739c049c569028284cbbb24ef5 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 02:46:48 +0200 Subject: [PATCH] Pre-render baked funnel routes --- package.json | 2 + scripts/bake-funnels.mjs | 79 +++ src/app/[funnelId]/[screenId]/page.tsx | 20 +- src/app/[funnelId]/page.tsx | 15 +- src/lib/funnel/bakedFunnels.ts | 799 +++++++++++++++++++++++++ src/lib/funnel/loadFunnelDefinition.ts | 56 +- 6 files changed, 953 insertions(+), 18 deletions(-) create mode 100644 scripts/bake-funnels.mjs create mode 100644 src/lib/funnel/bakedFunnels.ts diff --git a/package.json b/package.json index d0bfb5c..fef34d5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", + "bake:funnels": "node scripts/bake-funnels.mjs", + "prebuild": "npm run bake:funnels", "storybook": "storybook dev -p 6006 --ci", "build-storybook": "storybook build" }, diff --git a/scripts/bake-funnels.mjs b/scripts/bake-funnels.mjs new file mode 100644 index 0000000..0cdd50d --- /dev/null +++ b/scripts/bake-funnels.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import { promises as fs } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); +const funnelsDir = path.join(projectRoot, "public", "funnels"); +const outputFile = path.join(projectRoot, "src", "lib", "funnel", "bakedFunnels.ts"); + +function formatFunnelRecord(funnels) { + const entries = Object.entries(funnels) + .map(([funnelId, definition]) => { + const serialized = JSON.stringify(definition, null, 2); + const indented = serialized + .split("\n") + .map((line, index) => (index === 0 ? line : ` ${line}`)) + .join("\n"); + return ` "${funnelId}": ${indented}`; + }) + .join(",\n\n"); + + return `{ +${entries}\n}`; +} + +async function bakeFunnels() { + const dirExists = await fs + .access(funnelsDir) + .then(() => true) + .catch(() => false); + + if (!dirExists) { + throw new Error(`Funnels directory not found: ${funnelsDir}`); + } + + const files = (await fs.readdir(funnelsDir)).sort((a, b) => a.localeCompare(b)); + const funnels = {}; + + for (const file of files) { + if (!file.endsWith(".json")) continue; + + const filePath = path.join(funnelsDir, file); + const raw = await fs.readFile(filePath, "utf8"); + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse ${file}: ${error.message}`); + } + + const funnelId = parsed?.meta?.id ?? parsed?.id ?? file.replace(/\.json$/, ""); + + if (!funnelId || typeof funnelId !== "string") { + throw new Error( + `Unable to determine funnel id for '${file}'. Ensure the file contains an 'id' or 'meta.id' field.` + ); + } + + funnels[funnelId] = parsed; + } + + const headerComment = `/**\n * This file is auto-generated by scripts/bake-funnels.mjs.\n * Do not edit this file manually; update the source JSON files instead.\n */`; + + const recordLiteral = formatFunnelRecord(funnels); + const contents = `${headerComment}\n\nimport type { FunnelDefinition } from "./types";\n\nexport const BAKED_FUNNELS: Record = ${recordLiteral};\n`; + + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, contents, "utf8"); + + console.log(`Baked ${Object.keys(funnels).length} funnel(s) into ${path.relative(projectRoot, outputFile)}`); +} + +bakeFunnels().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/[funnelId]/[screenId]/page.tsx b/src/app/[funnelId]/[screenId]/page.tsx index 8bf55b4..aaf7995 100644 --- a/src/app/[funnelId]/[screenId]/page.tsx +++ b/src/app/[funnelId]/[screenId]/page.tsx @@ -1,7 +1,11 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition"; +import { + listBakedFunnelScreenParams, + peekBakedFunnelDefinition, + loadFunnelDefinition, +} from "@/lib/funnel/loadFunnelDefinition"; import { FunnelRuntime } from "@/components/funnel/FunnelRuntime"; interface FunnelScreenPageProps { @@ -11,11 +15,23 @@ interface FunnelScreenPageProps { }>; } +export const dynamic = "force-static"; + +export function generateStaticParams() { + return listBakedFunnelScreenParams(); +} + export async function generateMetadata({ params, }: FunnelScreenPageProps): Promise { const { funnelId } = await params; - const funnel = await loadFunnelDefinition(funnelId); + let funnel: ReturnType; + try { + funnel = peekBakedFunnelDefinition(funnelId); + } catch (error) { + console.error(`Failed to load funnel '${funnelId}' for metadata:`, error); + notFound(); + } return { title: funnel.meta.title ?? "Funnel", diff --git a/src/app/[funnelId]/page.tsx b/src/app/[funnelId]/page.tsx index f26c135..9a6611f 100644 --- a/src/app/[funnelId]/page.tsx +++ b/src/app/[funnelId]/page.tsx @@ -1,6 +1,15 @@ import { notFound, redirect } from "next/navigation"; -import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition"; +import { + listBakedFunnelIds, + peekBakedFunnelDefinition, +} from "@/lib/funnel/loadFunnelDefinition"; + +export const dynamic = "force-static"; + +export function generateStaticParams() { + return listBakedFunnelIds().map((funnelId) => ({ funnelId })); +} interface FunnelRootPageProps { params: Promise<{ @@ -11,9 +20,9 @@ interface FunnelRootPageProps { export default async function FunnelRootPage({ params }: FunnelRootPageProps) { const { funnelId } = await params; - let funnel; + let funnel: ReturnType; try { - funnel = await loadFunnelDefinition(funnelId); + funnel = peekBakedFunnelDefinition(funnelId); } catch (error) { console.error(`Failed to load funnel '${funnelId}':`, error); notFound(); diff --git a/src/lib/funnel/bakedFunnels.ts b/src/lib/funnel/bakedFunnels.ts new file mode 100644 index 0000000..e5e054b --- /dev/null +++ b/src/lib/funnel/bakedFunnels.ts @@ -0,0 +1,799 @@ +/** + * This file is auto-generated by scripts/bake-funnels.mjs. + * Do not edit this file manually; update the source JSON files instead. + */ + +import type { FunnelDefinition } from "./types"; + +export const BAKED_FUNNELS: Record = { + "funnel-test": { + "meta": { + "id": "funnel-test", + "title": "Relationship Portrait", + "description": "Demo funnel mirroring design screens with branching by analysis target.", + "firstScreenId": "intro-welcome" + }, + "defaultTexts": { + "nextButton": "Next", + "continueButton": "Continue" + }, + "screens": [ + { + "id": "intro-welcome", + "template": "info", + "title": { + "text": "Вы не одиноки в этом страхе", + "font": "manrope", + "weight": "bold", + "align": "center" + }, + "description": { + "text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.", + "font": "inter", + "weight": "medium", + "color": "default", + "align": "center" + }, + "icon": { + "type": "emoji", + "value": "❤️", + "size": "xl" + }, + "bottomActionButton": { + "text": "Next" + }, + "navigation": { + "defaultNextScreenId": "intro-statistics" + } + }, + { + "id": "intro-statistics", + "template": "info", + "title": { + "text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.", + "font": "manrope", + "weight": "bold", + "align": "center" + }, + "icon": { + "type": "emoji", + "value": "🔥❤️", + "size": "xl" + }, + "bottomActionButton": { + "text": "Next" + }, + "navigation": { + "defaultNextScreenId": "intro-partner-traits" + } + }, + { + "id": "intro-partner-traits", + "template": "info", + "header": { + "showBackButton": false + }, + "title": { + "text": "Такой партнёр умеет слышать и поддерживать, а вы — человек с глубокой душой, который ценит искренность и силу настоящих чувств.", + "font": "manrope", + "weight": "bold", + "align": "center" + }, + "icon": { + "type": "emoji", + "value": "💖", + "size": "xl" + }, + "bottomActionButton": { + "text": "Next" + }, + "navigation": { + "defaultNextScreenId": "birth-date" + } + }, + { + "id": "birth-date", + "template": "date", + "title": { + "text": "Когда ты родился?", + "font": "manrope", + "weight": "bold" + }, + "subtitle": { + "text": "В момент вашего рождения заложенны глубинные закономерности.", + "font": "inter", + "weight": "medium", + "color": "muted" + }, + "dateInput": { + "monthPlaceholder": "MM", + "dayPlaceholder": "DD", + "yearPlaceholder": "YYYY", + "monthLabel": "Month", + "dayLabel": "Day", + "yearLabel": "Year", + "showSelectedDate": true, + "selectedDateLabel": "Выбранная дата:" + }, + "infoMessage": { + "text": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.", + "font": "inter", + "weight": "medium", + "color": "muted" + }, + "bottomActionButton": { + "text": "Next" + }, + "navigation": { + "defaultNextScreenId": "address-form" + } + }, + { + "id": "address-form", + "template": "form", + "title": { + "text": "Which best represents your hair loss and goals?", + "font": "manrope", + "weight": "bold" + }, + "subtitle": { + "text": "Let's personalize your hair care journey", + "font": "inter", + "weight": "medium", + "color": "muted" + }, + "fields": [ + { + "id": "address", + "label": "Address", + "placeholder": "Enter your full address", + "type": "text", + "required": true, + "maxLength": 200 + } + ], + "validationMessages": { + "required": "${field} обязательно для заполнения", + "maxLength": "Максимум ${maxLength} символов", + "invalidFormat": "Неверный формат" + }, + "navigation": { + "defaultNextScreenId": "statistics-text" + } + }, + { + "id": "statistics-text", + "template": "info", + "title": { + "text": "Which best represents your hair loss and goals?", + "font": "manrope", + "weight": "bold", + "align": "center" + }, + "description": { + "text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.", + "font": "inter", + "weight": "medium", + "color": "default", + "align": "center" + } + }, + { + "id": "gender", + "template": "list", + "title": { + "text": "Какого ты пола?", + "font": "manrope", + "weight": "bold" + }, + "subtitle": { + "text": "Все начинается с тебя! Выбери свой пол.", + "font": "inter", + "weight": "medium", + "color": "muted" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "female", + "label": "FEMALE", + "emoji": "💗" + }, + { + "id": "male", + "label": "MALE", + "emoji": "💙" + } + ] + }, + "navigation": { + "defaultNextScreenId": "relationship-status" + } + }, + { + "id": "relationship-status", + "template": "list", + "title": { + "text": "Вы сейчас?", + "font": "manrope", + "weight": "bold" + }, + "subtitle": { + "text": "Это нужно, чтобы портрет и советы были точнее.", + "color": "muted" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "in-relationship", + "label": "В отношениях" + }, + { + "id": "single", + "label": "Свободны" + }, + { + "id": "after-breakup", + "label": "После расставания" + }, + { + "id": "complicated", + "label": "Все сложно" + } + ] + }, + "navigation": { + "defaultNextScreenId": "analysis-target" + } + }, + { + "id": "analysis-target", + "template": "list", + "title": { + "text": "Кого анализируем?", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "current-partner", + "label": "Текущего партнера" + }, + { + "id": "crush", + "label": "Человека, который нравится" + }, + { + "id": "ex-partner", + "label": "Бывшего" + }, + { + "id": "future-partner", + "label": "Будущую встречу" + } + ] + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "current-partner" + ] + } + ], + "nextScreenId": "current-partner-age" + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "crush" + ] + } + ], + "nextScreenId": "crush-age" + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "ex-partner" + ] + } + ], + "nextScreenId": "ex-partner-age" + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "future-partner" + ] + } + ], + "nextScreenId": "future-partner-age" + } + ], + "defaultNextScreenId": "current-partner-age" + } + }, + { + "id": "current-partner-age", + "template": "list", + "title": { + "text": "Возраст текущего партнера", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "under-29", + "label": "До 29" + }, + { + "id": "30-39", + "label": "30-39" + }, + { + "id": "40-49", + "label": "40-49" + }, + { + "id": "50-59", + "label": "50-59" + }, + { + "id": "60-plus", + "label": "60+" + } + ] + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "current-partner-age", + "operator": "includesAny", + "optionIds": [ + "under-29" + ] + } + ], + "nextScreenId": "age-refine" + } + ], + "defaultNextScreenId": "partner-ethnicity" + } + }, + { + "id": "crush-age", + "template": "list", + "title": { + "text": "Возраст человека, который нравится", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "under-29", + "label": "До 29" + }, + { + "id": "30-39", + "label": "30-39" + }, + { + "id": "40-49", + "label": "40-49" + }, + { + "id": "50-59", + "label": "50-59" + }, + { + "id": "60-plus", + "label": "60+" + } + ] + }, + "bottomActionButton": { + "show": false + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "crush-age", + "operator": "includesAny", + "optionIds": [ + "under-29" + ] + } + ], + "nextScreenId": "age-refine" + } + ], + "defaultNextScreenId": "partner-ethnicity" + } + }, + { + "id": "ex-partner-age", + "template": "list", + "title": { + "text": "Возраст бывшего", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "under-29", + "label": "До 29" + }, + { + "id": "30-39", + "label": "30-39" + }, + { + "id": "40-49", + "label": "40-49" + }, + { + "id": "50-59", + "label": "50-59" + }, + { + "id": "60-plus", + "label": "60+" + } + ] + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "ex-partner-age", + "operator": "includesAny", + "optionIds": [ + "under-29" + ] + } + ], + "nextScreenId": "age-refine" + } + ], + "defaultNextScreenId": "partner-ethnicity" + } + }, + { + "id": "future-partner-age", + "template": "list", + "title": { + "text": "Возраст будущего партнера", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "under-29", + "label": "До 29" + }, + { + "id": "30-39", + "label": "30-39" + }, + { + "id": "40-49", + "label": "40-49" + }, + { + "id": "50-59", + "label": "50-59" + }, + { + "id": "60-plus", + "label": "60+" + } + ] + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "future-partner-age", + "operator": "includesAny", + "optionIds": [ + "under-29" + ] + } + ], + "nextScreenId": "age-refine" + } + ], + "defaultNextScreenId": "partner-ethnicity" + } + }, + { + "id": "age-refine", + "template": "list", + "title": { + "text": "Уточните чуть точнее", + "font": "manrope", + "weight": "bold" + }, + "subtitle": { + "text": "Чтобы портрет был максимально похож.", + "color": "muted" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "18-21", + "label": "18-21" + }, + { + "id": "22-25", + "label": "22-25" + }, + { + "id": "26-29", + "label": "26-29" + } + ] + }, + "navigation": { + "defaultNextScreenId": "partner-ethnicity" + } + }, + { + "id": "partner-ethnicity", + "template": "list", + "title": { + "text": "Этническая принадлежность твоей второй половинки?", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "white", + "label": "White" + }, + { + "id": "hispanic", + "label": "Hispanic / Latino" + }, + { + "id": "african", + "label": "African / African-American" + }, + { + "id": "asian", + "label": "Asian" + }, + { + "id": "south-asian", + "label": "Indian / South Asian" + }, + { + "id": "middle-eastern", + "label": "Middle Eastern / Arab" + }, + { + "id": "indigenous", + "label": "Native American / Indigenous" + }, + { + "id": "no-preference", + "label": "No preference" + } + ] + }, + "navigation": { + "defaultNextScreenId": "partner-eyes" + } + }, + { + "id": "partner-eyes", + "template": "list", + "title": { + "text": "Что из этого «про глаза»?", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "warm-glow", + "label": "Тёплые искры на свету" + }, + { + "id": "clear-depth", + "label": "Прозрачная глубина" + }, + { + "id": "green-sheen", + "label": "Зелёный отлив на границе зрачка" + }, + { + "id": "steel-glint", + "label": "Холодный стальной отблеск" + }, + { + "id": "deep-shadow", + "label": "Насыщенная темнота" + }, + { + "id": "dont-know", + "label": "Не знаю" + } + ] + }, + "navigation": { + "defaultNextScreenId": "partner-hair-length" + } + }, + { + "id": "partner-hair-length", + "template": "list", + "title": { + "text": "Выберите длину волос", + "font": "manrope", + "weight": "bold" + }, + "subtitle": { + "text": "От неё зависит форма и настроение портрета.", + "color": "muted" + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "short", + "label": "Короткие" + }, + { + "id": "medium", + "label": "Средние" + }, + { + "id": "long", + "label": "Длинные" + } + ] + }, + "navigation": { + "defaultNextScreenId": "burnout-support" + } + }, + { + "id": "burnout-support", + "template": "list", + "title": { + "text": "Когда ты выгораешь, тебе нужно чтобы партнёр...", + "font": "manrope", + "weight": "bold" + }, + "list": { + "selectionType": "multi", + "options": [ + { + "id": "reassure", + "label": "Признал ваше разочарование и успокоил" + }, + { + "id": "emotional-support", + "label": "Дал эмоциональную опору и безопасное пространство" + }, + { + "id": "take-over", + "label": "Перехватил быт/дела, чтобы вы восстановились" + }, + { + "id": "energize", + "label": "Вдохнул энергию через цель и короткий план действий" + }, + { + "id": "switch-positive", + "label": "Переключил на позитив: прогулка, кино, смешные истории" + } + ] + }, + "bottomActionButton": { + "text": "Continue", + "show": false + }, + "navigation": { + "defaultNextScreenId": "special-offer" + } + }, + { + "id": "special-offer", + "template": "coupon", + "header": { + "show": false + }, + "title": { + "text": "Тебе повезло!", + "font": "manrope", + "weight": "bold", + "align": "center" + }, + "subtitle": { + "text": "Ты получил специальную эксклюзивную скидку на 94%", + "font": "inter", + "weight": "medium", + "color": "muted", + "align": "center" + }, + "copiedMessage": "Промокод \"{code}\" скопирован!", + "coupon": { + "title": { + "text": "Special Offer", + "font": "manrope", + "weight": "bold", + "color": "primary" + }, + "offer": { + "title": { + "text": "94% OFF", + "font": "manrope", + "weight": "black", + "color": "card", + "size": "4xl" + }, + "description": { + "text": "Одноразовая эксклюзивная скидка", + "font": "inter", + "weight": "semiBold", + "color": "card" + } + }, + "promoCode": { + "text": "HAIR50", + "font": "inter", + "weight": "semiBold" + }, + "footer": { + "text": "Скопируйте или нажмите Continue", + "font": "inter", + "weight": "medium", + "color": "muted", + "size": "sm" + } + }, + "bottomActionButton": { + "text": "Continue" + } + } + ] + } +}; diff --git a/src/lib/funnel/loadFunnelDefinition.ts b/src/lib/funnel/loadFunnelDefinition.ts index 822220d..7abd893 100644 --- a/src/lib/funnel/loadFunnelDefinition.ts +++ b/src/lib/funnel/loadFunnelDefinition.ts @@ -1,20 +1,50 @@ -import { promises as fs } from "fs"; -import path from "path"; +import { BAKED_FUNNELS } from "./bakedFunnels"; +import type { FunnelDefinition } from "./types"; -import { FunnelDefinition } from "./types"; +function resolveBakedFunnel(funnelId: string): FunnelDefinition { + const funnel = BAKED_FUNNELS[funnelId]; + + if (!funnel) { + throw new Error(`Funnel '${funnelId}' is not baked.`); + } + + return funnel; +} + +function cloneFunnel(value: T): T { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + + return JSON.parse(JSON.stringify(value)); +} export async function loadFunnelDefinition( funnelId: string ): Promise { - const filePath = path.join( - process.cwd(), - "public", - "funnels", - `${funnelId}.json` - ); + const funnel = resolveBakedFunnel(funnelId); - const raw = await fs.readFile(filePath, "utf-8"); - const parsed = JSON.parse(raw) as FunnelDefinition; - - return parsed; + return cloneFunnel(funnel); +} + +export function peekBakedFunnelDefinition( + funnelId: string +): FunnelDefinition { + return resolveBakedFunnel(funnelId); +} + +export function listBakedFunnelIds(): string[] { + return Object.keys(BAKED_FUNNELS); +} + +export function listBakedFunnelScreenParams(): Array<{ + funnelId: string; + screenId: string; +}> { + return Object.entries(BAKED_FUNNELS).flatMap(([funnelId, funnel]) => + funnel.screens.map((screen) => ({ + funnelId, + screenId: screen.id, + })) + ); }