Pre-render baked funnel routes
This commit is contained in:
parent
22c6d513af
commit
084de6c05b
@ -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"
|
||||
},
|
||||
|
||||
79
scripts/bake-funnels.mjs
Normal file
79
scripts/bake-funnels.mjs
Normal file
@ -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<string, FunnelDefinition> = ${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);
|
||||
});
|
||||
@ -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<Metadata> {
|
||||
const { funnelId } = await params;
|
||||
const funnel = await loadFunnelDefinition(funnelId);
|
||||
let funnel: ReturnType<typeof peekBakedFunnelDefinition>;
|
||||
try {
|
||||
funnel = peekBakedFunnelDefinition(funnelId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}' for metadata:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
title: funnel.meta.title ?? "Funnel",
|
||||
|
||||
@ -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<typeof peekBakedFunnelDefinition>;
|
||||
try {
|
||||
funnel = await loadFunnelDefinition(funnelId);
|
||||
funnel = peekBakedFunnelDefinition(funnelId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}':`, error);
|
||||
notFound();
|
||||
|
||||
799
src/lib/funnel/bakedFunnels.ts
Normal file
799
src/lib/funnel/bakedFunnels.ts
Normal file
@ -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<string, FunnelDefinition> = {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
@ -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<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function loadFunnelDefinition(
|
||||
funnelId: string
|
||||
): Promise<FunnelDefinition> {
|
||||
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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user