From b28d22967f49dc6a712ed0ea566d620436fa10f6 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Sun, 28 Sep 2025 16:35:41 +0200 Subject: [PATCH] story --- .../CouponTemplate/CouponTemplate.stories.tsx | 109 ++++++++ .../CouponTemplate.tsx | 0 .../funnel/templates/CouponTemplate/index.ts | 1 + .../DateTemplate/DateTemplate.stories.tsx | 133 ++++++++++ .../{forms => DateTemplate}/DateTemplate.tsx | 48 ++++ .../funnel/templates/DateTemplate/index.ts | 1 + .../EmailTemplate/EmailTemplate.stories.tsx | 93 +++++++ .../EmailTemplate.tsx | 0 .../funnel/templates/EmailTemplate/index.ts | 1 + .../FormTemplate/FormTemplate.stories.tsx | 152 +++++++++++ .../{forms => FormTemplate}/FormTemplate.tsx | 0 .../funnel/templates/FormTemplate/index.ts | 1 + .../InfoTemplate/InfoTemplate.stories.tsx | 95 +++++++ .../InfoTemplate.tsx | 0 .../funnel/templates/InfoTemplate/index.ts | 1 + .../ListTemplate/ListTemplate.stories.tsx | 144 +++++++++++ .../ListTemplate.tsx | 3 +- .../funnel/templates/ListTemplate/index.ts | 1 + .../LoadersTemplate.stories.tsx | 112 ++++++++ .../LoadersTemplate.tsx | 0 .../funnel/templates/LoadersTemplate/index.ts | 1 + .../SoulmatePortraitTemplate.stories.tsx | 114 +++++++++ .../SoulmatePortraitTemplate.tsx | 0 .../SoulmatePortraitTemplate/index.ts | 1 + .../funnel/templates/content/index.ts | 4 - .../funnel/templates/forms/index.ts | 4 - src/components/funnel/templates/index.ts | 23 +- .../funnel/templates/interactive/index.ts | 3 - .../layouts/TemplateLayout.stories.tsx | 241 ++++++++++++++++++ .../templates/layouts/TemplateLayout.tsx | 6 +- .../admin/builder/state/defaults/blocks.ts | 1 + .../admin/builder/state/defaults/coupon.ts | 4 +- src/lib/funnel/mappers.tsx | 17 +- src/lib/funnel/types.ts | 2 + 34 files changed, 1281 insertions(+), 35 deletions(-) create mode 100644 src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx rename src/components/funnel/templates/{interactive => CouponTemplate}/CouponTemplate.tsx (100%) create mode 100644 src/components/funnel/templates/CouponTemplate/index.ts create mode 100644 src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx rename src/components/funnel/templates/{forms => DateTemplate}/DateTemplate.tsx (69%) create mode 100644 src/components/funnel/templates/DateTemplate/index.ts create mode 100644 src/components/funnel/templates/EmailTemplate/EmailTemplate.stories.tsx rename src/components/funnel/templates/{forms => EmailTemplate}/EmailTemplate.tsx (100%) create mode 100644 src/components/funnel/templates/EmailTemplate/index.ts create mode 100644 src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx rename src/components/funnel/templates/{forms => FormTemplate}/FormTemplate.tsx (100%) create mode 100644 src/components/funnel/templates/FormTemplate/index.ts create mode 100644 src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx rename src/components/funnel/templates/{content => InfoTemplate}/InfoTemplate.tsx (100%) create mode 100644 src/components/funnel/templates/InfoTemplate/index.ts create mode 100644 src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx rename src/components/funnel/templates/{interactive => ListTemplate}/ListTemplate.tsx (95%) create mode 100644 src/components/funnel/templates/ListTemplate/index.ts create mode 100644 src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx rename src/components/funnel/templates/{content => LoadersTemplate}/LoadersTemplate.tsx (100%) create mode 100644 src/components/funnel/templates/LoadersTemplate/index.ts create mode 100644 src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx rename src/components/funnel/templates/{content => SoulmatePortraitTemplate}/SoulmatePortraitTemplate.tsx (100%) create mode 100644 src/components/funnel/templates/SoulmatePortraitTemplate/index.ts delete mode 100644 src/components/funnel/templates/content/index.ts delete mode 100644 src/components/funnel/templates/forms/index.ts delete mode 100644 src/components/funnel/templates/interactive/index.ts create mode 100644 src/components/funnel/templates/layouts/TemplateLayout.stories.tsx diff --git a/src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx b/src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx new file mode 100644 index 0000000..6355cb8 --- /dev/null +++ b/src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx @@ -0,0 +1,109 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { CouponTemplate } from "./CouponTemplate"; +import { fn } from "storybook/test"; +import { buildCouponDefaults } from "@/lib/admin/builder/state/defaults/coupon"; +import type { CouponScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildCouponDefaults("coupon-screen-story") as CouponScreenDefinition; + +/** CouponTemplate - экраны с купонами и промокодами */ +const meta: Meta = { + title: "Funnel Templates/CouponTemplate", + component: CouponTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 8, total: 10 }, + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный купон экран */ +export const Default: Story = {}; + +/** Купон с показом прогресса */ +export const WithProgress: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: true, + showBackButton: true, + showProgress: true, // Показываем прогресс + }, + }, + }, +}; + +/** Экран без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Купон с другой скидкой */ +export const CustomDiscount: Story = { + args: { + screen: { + ...defaultScreen, + coupon: { + ...defaultScreen.coupon, + offer: { + title: { + text: "50% OFF", + font: "manrope", + weight: "bold", + align: "center", + size: "3xl", + color: "primary", + }, + description: { + text: "Скидка на первую покупку", + font: "inter", + weight: "medium", + color: "muted", + align: "center", + size: "md", + }, + }, + promoCode: { + text: "FIRST50", + font: "geistMono", + weight: "bold", + align: "center", + size: "lg", + color: "accent", + }, + }, + }, + }, +}; diff --git a/src/components/funnel/templates/interactive/CouponTemplate.tsx b/src/components/funnel/templates/CouponTemplate/CouponTemplate.tsx similarity index 100% rename from src/components/funnel/templates/interactive/CouponTemplate.tsx rename to src/components/funnel/templates/CouponTemplate/CouponTemplate.tsx diff --git a/src/components/funnel/templates/CouponTemplate/index.ts b/src/components/funnel/templates/CouponTemplate/index.ts new file mode 100644 index 0000000..8d4f114 --- /dev/null +++ b/src/components/funnel/templates/CouponTemplate/index.ts @@ -0,0 +1 @@ +export { CouponTemplate } from "./CouponTemplate"; diff --git a/src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx b/src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx new file mode 100644 index 0000000..817830c --- /dev/null +++ b/src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx @@ -0,0 +1,133 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { DateTemplate } from "./DateTemplate"; +import { fn } from "storybook/test"; +import { buildDateDefaults } from "@/lib/admin/builder/state/defaults/date"; +import type { DateScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildDateDefaults("date-screen-story") as DateScreenDefinition; + +/** DateTemplate - экраны с выбором даты рождения */ +const meta: Meta = { + title: "Funnel Templates/DateTemplate", + component: DateTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + selectedDate: {}, + onDateChange: fn(), + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 4, total: 10 }, + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + selectedDate: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onDateChange: { action: "date changed" }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный экран выбора даты */ +export const Default: Story = {}; + +/** Экран с предзаполненной датой */ +export const WithPrefilledDate: Story = { + args: { + selectedDate: { + month: "4", + day: "8", + year: "1987", + }, + }, +}; + +/** Экран без показа выбранной даты */ +export const WithoutSelectedDate: Story = { + args: { + screen: { + ...defaultScreen, + dateInput: { + ...defaultScreen.dateInput, + showSelectedDate: false, + }, + }, + }, +}; + +/** Экран с кастомными лейблами */ +export const CustomLabels: Story = { + args: { + screen: { + ...defaultScreen, + dateInput: { + ...defaultScreen.dateInput, + monthLabel: "Month", + dayLabel: "Day", + yearLabel: "Year", + monthPlaceholder: "MM", + dayPlaceholder: "DD", + yearPlaceholder: "YYYY", + selectedDateLabel: "Selected date:", + selectedDateFormat: "MMMM d, yyyy", + }, + }, + }, +}; + +/** Экран без информационного сообщения */ +export const WithoutInfoMessage: Story = { + args: { + screen: { + ...defaultScreen, + infoMessage: undefined, + }, + }, +}; + +/** Экран с включенным зодиаком */ +export const WithZodiac: Story = { + args: { + screen: { + ...defaultScreen, + dateInput: { + ...defaultScreen.dateInput, + zodiac: { + enabled: true, + storageKey: "zodiac_sign", + }, + }, + }, + }, +}; + +/** Экран без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: false, + }, + }, + }, +}; diff --git a/src/components/funnel/templates/forms/DateTemplate.tsx b/src/components/funnel/templates/DateTemplate/DateTemplate.tsx similarity index 69% rename from src/components/funnel/templates/forms/DateTemplate.tsx rename to src/components/funnel/templates/DateTemplate/DateTemplate.tsx index 59cbf20..995ad34 100644 --- a/src/components/funnel/templates/forms/DateTemplate.tsx +++ b/src/components/funnel/templates/DateTemplate/DateTemplate.tsx @@ -9,6 +9,21 @@ import type { DateScreenDefinition } from "@/lib/funnel/types"; import { cn } from "@/lib/utils"; import { TemplateLayout } from "../layouts/TemplateLayout"; +// Утилита для форматирования даты на основе паттерна +function formatDateByPattern(date: Date, pattern: string): string { + const monthNames = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + + return pattern + .replace("MMMM", monthNames[date.getMonth()]) + .replace("MMM", monthNames[date.getMonth()].substring(0, 3)) + .replace("yyyy", date.getFullYear().toString()) + .replace("dd", date.getDate().toString().padStart(2, '0')) + .replace("d", date.getDate().toString()); +} + interface DateTemplateProps { screen: DateScreenDefinition; selectedDate: { month?: string; day?: string; year?: string }; @@ -67,6 +82,38 @@ export function DateTemplate({ const isFormValid = Boolean(isoDate); + // Форматированная дата для отображения + const formattedDate = useMemo(() => { + if (!isoDate) return null; + + const date = new Date(isoDate); + const pattern = screen.dateInput?.selectedDateFormat || "MMMM d, yyyy"; + return formatDateByPattern(date, pattern); + }, [isoDate, screen.dateInput?.selectedDateFormat]); + + // Компонент отображения выбранной даты над кнопкой + const selectedDateDisplay = formattedDate && screen.dateInput?.showSelectedDate !== false ? ( +
+ + {screen.dateInput?.selectedDateLabel || "Выбранная дата:"} + + + {formattedDate} + +
+ ) : null; + return (
= { + title: "Funnel Templates/EmailTemplate", + component: EmailTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 9, total: 10 }, + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный email экран */ +export const Default: Story = {}; + +/** Экран без изображения */ +export const WithoutImage: Story = { + args: { + screen: { + ...defaultScreen, + image: undefined, + }, + }, +}; + +/** Экран с consent */ +export const WithConsent: Story = { + args: { + screen: { + ...defaultScreen, + bottomActionButton: { + ...defaultScreen.bottomActionButton, + showPrivacyTermsConsent: true, + }, + }, + }, +}; + +/** Экран без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Экран с кастомными лейблами */ +export const CustomLabels: Story = { + args: { + screen: { + ...defaultScreen, + emailInput: { + label: "Your Email Address", + placeholder: "Enter your email here...", + }, + }, + }, +}; diff --git a/src/components/funnel/templates/forms/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx similarity index 100% rename from src/components/funnel/templates/forms/EmailTemplate.tsx rename to src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx diff --git a/src/components/funnel/templates/EmailTemplate/index.ts b/src/components/funnel/templates/EmailTemplate/index.ts new file mode 100644 index 0000000..047457d --- /dev/null +++ b/src/components/funnel/templates/EmailTemplate/index.ts @@ -0,0 +1 @@ +export { EmailTemplate } from "./EmailTemplate"; diff --git a/src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx b/src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx new file mode 100644 index 0000000..7929f13 --- /dev/null +++ b/src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx @@ -0,0 +1,152 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { FormTemplate } from "./FormTemplate"; +import { fn } from "storybook/test"; +import { buildFormDefaults } from "@/lib/admin/builder/state/defaults/form"; +import type { FormScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildFormDefaults("form-screen-story") as FormScreenDefinition; + +// Создаем более богатую форму для демонстрации +const richFormScreen: FormScreenDefinition = { + ...defaultScreen, + title: { + ...defaultScreen.title, + text: "Расскажите о себе", + }, + subtitle: { + ...defaultScreen.subtitle, + text: "Заполните форму для персонализированного анализа", + }, + fields: [ + { + id: "name", + label: "Полное имя", + placeholder: "Введите ваше имя", + type: "text", + required: true, + maxLength: 50, + }, + { + id: "email", + label: "Email адрес", + placeholder: "example@email.com", + type: "email", + required: true, + }, + { + id: "phone", + label: "Телефон", + placeholder: "+7 (999) 123-45-67", + type: "tel", + required: false, + }, + { + id: "website", + label: "Веб-сайт", + placeholder: "https://example.com", + type: "url", + required: false, + }, + ], +}; + +/** FormTemplate - экраны с формами ввода */ +const meta: Meta = { + title: "Funnel Templates/FormTemplate", + component: FormTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: richFormScreen, + formData: {}, + onFormDataChange: fn(), + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 6, total: 10 }, + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + formData: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onFormDataChange: { action: "form data changed" }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтная форма */ +export const Default: Story = {}; + +/** Простая форма с одним полем */ +export const SimpleForm: Story = { + args: { + screen: defaultScreen, // Используем базовые дефолты + }, +}; + +/** Форма только с обязательными полями */ +export const RequiredFieldsOnly: Story = { + args: { + screen: { + ...richFormScreen, + fields: richFormScreen.fields.filter(field => field.required), + }, + }, +}; + +/** Форма с кастомными сообщениями валидации */ +export const CustomValidation: Story = { + args: { + screen: { + ...richFormScreen, + validationMessages: { + required: "Пожалуйста, заполните это поле", + maxLength: "Слишком длинное значение", + invalidFormat: "Неправильный формат данных", + }, + }, + }, +}; + +/** Форма без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...richFormScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Форма без subtitle */ +export const WithoutSubtitle: Story = { + args: { + screen: { + ...richFormScreen, + subtitle: { + ...richFormScreen.subtitle, + show: false, + text: richFormScreen.subtitle?.text || "", + }, + }, + }, +}; diff --git a/src/components/funnel/templates/forms/FormTemplate.tsx b/src/components/funnel/templates/FormTemplate/FormTemplate.tsx similarity index 100% rename from src/components/funnel/templates/forms/FormTemplate.tsx rename to src/components/funnel/templates/FormTemplate/FormTemplate.tsx diff --git a/src/components/funnel/templates/FormTemplate/index.ts b/src/components/funnel/templates/FormTemplate/index.ts new file mode 100644 index 0000000..0ed0701 --- /dev/null +++ b/src/components/funnel/templates/FormTemplate/index.ts @@ -0,0 +1 @@ +export { FormTemplate } from "./FormTemplate"; diff --git a/src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx b/src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx new file mode 100644 index 0000000..6f1d653 --- /dev/null +++ b/src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx @@ -0,0 +1,95 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { InfoTemplate } from "./InfoTemplate"; +import { fn } from "storybook/test"; +import { buildInfoDefaults } from "@/lib/admin/builder/state/defaults/info"; +import type { InfoScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildInfoDefaults("info-screen-story") as InfoScreenDefinition; + +/** InfoTemplate - информационные экраны с иконкой и описанием */ +const meta: Meta = { + title: "Funnel Templates/InfoTemplate", + component: InfoTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 3, total: 10 }, + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный информационный экран */ +export const Default: Story = {}; + +/** Экран без иконки */ +export const WithoutIcon: Story = { + args: { + screen: { + ...defaultScreen, + icon: undefined, + }, + }, +}; + +/** Экран с кастомной иконкой */ +export const WithCustomIcon: Story = { + args: { + screen: { + ...defaultScreen, + icon: { + type: "emoji", + value: "🎯", + size: "lg", + }, + }, + }, +}; + +/** Экран без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Экран с скрытым прогрессом */ +export const WithoutProgress: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: true, + showBackButton: true, + showProgress: false, + }, + }, + }, +}; diff --git a/src/components/funnel/templates/content/InfoTemplate.tsx b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx similarity index 100% rename from src/components/funnel/templates/content/InfoTemplate.tsx rename to src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx diff --git a/src/components/funnel/templates/InfoTemplate/index.ts b/src/components/funnel/templates/InfoTemplate/index.ts new file mode 100644 index 0000000..36710fb --- /dev/null +++ b/src/components/funnel/templates/InfoTemplate/index.ts @@ -0,0 +1 @@ +export { InfoTemplate } from "./InfoTemplate"; diff --git a/src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx b/src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx new file mode 100644 index 0000000..fc4b716 --- /dev/null +++ b/src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx @@ -0,0 +1,144 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { ListTemplate } from "./ListTemplate"; +import { fn } from "storybook/test"; +import { buildListDefaults } from "@/lib/admin/builder/state/defaults/list"; +import type { ListScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildListDefaults("list-screen-story") as ListScreenDefinition; + +// Более богатый список опций для демонстрации +const richOptionsScreen: ListScreenDefinition = { + ...defaultScreen, + title: { + ...defaultScreen.title, + text: "Выберите ваш знак зодиака", + }, + list: { + ...defaultScreen.list, + options: [ + { id: "aries", label: "Овен", emoji: "♈" }, + { id: "taurus", label: "Телец", emoji: "♉" }, + { id: "gemini", label: "Близнецы", emoji: "♊" }, + { id: "cancer", label: "Рак", emoji: "♋" }, + { id: "leo", label: "Лев", emoji: "♌" }, + { id: "virgo", label: "Дева", emoji: "♍" }, + ], + }, +}; + +/** ListTemplate - экраны с выбором опций (single/multi selection) */ +const meta: Meta = { + title: "Funnel Templates/ListTemplate", + component: ListTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: richOptionsScreen, + selectedOptionIds: [], + onSelectionChange: fn(), + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + canGoBack: true, + onBack: fn(), + screenProgress: { current: 5, total: 10 }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + selectedOptionIds: { + control: { type: "object" }, + }, + actionButtonProps: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onSelectionChange: { action: "selection changed" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный single selection список (кнопка disabled пока не выбрано) */ +export const SingleSelection: Story = {}; + +/** Multi selection список (кнопка disabled пока не выбрано) */ +export const MultiSelection: Story = { + args: { + screen: { + ...richOptionsScreen, + title: { + ...richOptionsScreen.title, + text: "Выберите несколько вариантов", + }, + list: { + ...richOptionsScreen.list, + selectionType: "multi", + }, + }, + }, +}; + +/** Single selection с предвыбранным значением (кнопка активна) */ +export const WithPreselection: Story = { + args: { + selectedOptionIds: ["leo"], + }, +}; + +/** Multi selection с несколькими выбранными (кнопка активна) */ +export const MultiWithPreselection: Story = { + args: { + screen: { + ...richOptionsScreen, + list: { + ...richOptionsScreen.list, + selectionType: "multi", + }, + }, + selectedOptionIds: ["aries", "leo", "virgo"], + }, +}; + +/** Single selection с автопереходом (без кнопки) */ +export const SingleAutoAdvance: Story = { + args: { + screen: { + ...richOptionsScreen, + bottomActionButton: { + show: false, // Автопереход при выборе + }, + }, + actionButtonProps: undefined, // Нет кнопки + }, +}; + +/** Список без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...richOptionsScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Простой список без эмодзи */ +export const SimpleList: Story = { + args: { + screen: { + ...defaultScreen, // Используем базовые дефолты без эмодзи + }, + }, +}; diff --git a/src/components/funnel/templates/interactive/ListTemplate.tsx b/src/components/funnel/templates/ListTemplate/ListTemplate.tsx similarity index 95% rename from src/components/funnel/templates/interactive/ListTemplate.tsx rename to src/components/funnel/templates/ListTemplate/ListTemplate.tsx index afd1cc1..d4a1d63 100644 --- a/src/components/funnel/templates/interactive/ListTemplate.tsx +++ b/src/components/funnel/templates/ListTemplate/ListTemplate.tsx @@ -93,7 +93,8 @@ export function ListTemplate({ const actionButtonOptions = actionButtonProps ? { defaultText: actionButtonProps.children as string || "Next", - disabled: actionButtonProps.disabled || false, + // Кнопка неактивна если: 1) disabled из props ИЛИ 2) ничего не выбрано + disabled: actionButtonProps.disabled || selectedOptionIds.length === 0, onClick: () => { if (actionButtonProps.onClick) { actionButtonProps.onClick({} as React.MouseEvent); diff --git a/src/components/funnel/templates/ListTemplate/index.ts b/src/components/funnel/templates/ListTemplate/index.ts new file mode 100644 index 0000000..dfb163d --- /dev/null +++ b/src/components/funnel/templates/ListTemplate/index.ts @@ -0,0 +1 @@ +export { ListTemplate } from "./ListTemplate"; diff --git a/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx new file mode 100644 index 0000000..850ab71 --- /dev/null +++ b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx @@ -0,0 +1,112 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { LoadersTemplate } from "./LoadersTemplate"; +import { fn } from "storybook/test"; +import { buildLoadersDefaults } from "@/lib/admin/builder/state/defaults/loaders"; +import type { LoadersScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildLoadersDefaults("loaders-screen-story") as LoadersScreenDefinition; + +/** LoadersTemplate - экраны с анимированными загрузчиками */ +const meta: Meta = { + title: "Funnel Templates/LoadersTemplate", + component: LoadersTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: false, // Обычно на лоадерах нет кнопки назад + onBack: fn(), + screenProgress: undefined, // У лоадеров обычно нет прогресса + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный loaders экран */ +export const Default: Story = {}; + +/** Лоадеры с быстрой анимацией */ +export const FastAnimation: Story = { + args: { + screen: { + ...defaultScreen, + progressbars: { + ...defaultScreen.progressbars, + transitionDuration: 1000, // Быстрее + }, + }, + }, +}; + +/** Лоадеры с медленной анимацией */ +export const SlowAnimation: Story = { + args: { + screen: { + ...defaultScreen, + progressbars: { + ...defaultScreen.progressbars, + transitionDuration: 5000, // Медленнее + }, + }, + }, +}; + +/** Лоадеры с кастомными сообщениями */ +export const CustomMessages: Story = { + args: { + screen: { + ...defaultScreen, + progressbars: { + ...defaultScreen.progressbars, + items: [ + { + processingTitle: "Анализируем ваши ответы...", + processingSubtitle: "Обработка данных", + completedTitle: "Анализ завершен", + completedSubtitle: "Готово!", + }, + { + processingTitle: "Создаем персональный портрет...", + processingSubtitle: "Генерация", + completedTitle: "Портрет готов", + completedSubtitle: "Завершено", + }, + ], + }, + }, + }, +}; + +/** Лоадеры с header (необычно, но возможно) */ +export const WithHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: true, + showBackButton: false, + showProgress: true, + }, + }, + screenProgress: { current: 7, total: 10 }, + }, +}; diff --git a/src/components/funnel/templates/content/LoadersTemplate.tsx b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.tsx similarity index 100% rename from src/components/funnel/templates/content/LoadersTemplate.tsx rename to src/components/funnel/templates/LoadersTemplate/LoadersTemplate.tsx diff --git a/src/components/funnel/templates/LoadersTemplate/index.ts b/src/components/funnel/templates/LoadersTemplate/index.ts new file mode 100644 index 0000000..7c8a764 --- /dev/null +++ b/src/components/funnel/templates/LoadersTemplate/index.ts @@ -0,0 +1 @@ +export { LoadersTemplate } from "./LoadersTemplate"; diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx new file mode 100644 index 0000000..afabd51 --- /dev/null +++ b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx @@ -0,0 +1,114 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; +import { fn } from "storybook/test"; +import { buildSoulmateDefaults } from "@/lib/admin/builder/state/defaults/soulmate"; +import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildSoulmateDefaults("soulmate-screen-story") as SoulmatePortraitScreenDefinition; + +/** SoulmatePortraitTemplate - результирующие экраны с портретом партнера */ +const meta: Meta = { + title: "Funnel Templates/SoulmatePortraitTemplate", + component: SoulmatePortraitTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 10, total: 10 }, // Обычно финальный экран + defaultTexts: { + nextButton: "Next", + continueButton: "Continue" + }, + }, + argTypes: { + screen: { + control: { type: "object" }, + }, + screenProgress: { + control: { type: "object" }, + }, + onContinue: { action: "continue" }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный soulmate portrait экран */ +export const Default: Story = {}; + +/** Экран без описания */ +export const WithoutDescription: Story = { + args: { + screen: { + ...defaultScreen, + description: undefined, + }, + }, +}; + +/** Экран с кастомным описанием */ +export const CustomDescription: Story = { + args: { + screen: { + ...defaultScreen, + description: { + text: "На основе ваших ответов мы создали уникальный **портрет вашей второй половинки**. Этот анализ поможет вам лучше понять, кто может стать идеальным партнером.", + font: "inter", + weight: "regular", + align: "center", + size: "md", + color: "default", + }, + }, + }, +}; + +/** Экран без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Экран без subtitle */ +export const WithoutSubtitle: Story = { + args: { + screen: { + ...defaultScreen, + subtitle: { + ...defaultScreen.subtitle, + show: false, + text: defaultScreen.subtitle?.text || "", + }, + }, + }, +}; + +/** Финальный экран (без прогресса) */ +export const FinalScreen: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: true, + showBackButton: false, // На финальном экране обычно нет кнопки назад + showProgress: false, // И нет прогресса + }, + }, + screenProgress: undefined, + canGoBack: false, + }, +}; diff --git a/src/components/funnel/templates/content/SoulmatePortraitTemplate.tsx b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.tsx similarity index 100% rename from src/components/funnel/templates/content/SoulmatePortraitTemplate.tsx rename to src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.tsx diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate/index.ts b/src/components/funnel/templates/SoulmatePortraitTemplate/index.ts new file mode 100644 index 0000000..3d17617 --- /dev/null +++ b/src/components/funnel/templates/SoulmatePortraitTemplate/index.ts @@ -0,0 +1 @@ +export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; diff --git a/src/components/funnel/templates/content/index.ts b/src/components/funnel/templates/content/index.ts deleted file mode 100644 index 23224cd..0000000 --- a/src/components/funnel/templates/content/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Content templates - informational and display screens -export { InfoTemplate } from "./InfoTemplate"; -export { LoadersTemplate } from "./LoadersTemplate"; -export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; diff --git a/src/components/funnel/templates/forms/index.ts b/src/components/funnel/templates/forms/index.ts deleted file mode 100644 index 47084e1..0000000 --- a/src/components/funnel/templates/forms/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Form templates - input and data collection screens -export { FormTemplate } from "./FormTemplate"; -export { DateTemplate } from "./DateTemplate"; -export { EmailTemplate } from "./EmailTemplate"; diff --git a/src/components/funnel/templates/index.ts b/src/components/funnel/templates/index.ts index 9b9899d..cf85da7 100644 --- a/src/components/funnel/templates/index.ts +++ b/src/components/funnel/templates/index.ts @@ -1,13 +1,12 @@ -// Funnel templates organized by category +// Funnel Templates - каждый в своей папке с stories +export { InfoTemplate } from "./InfoTemplate"; +export { ListTemplate } from "./ListTemplate"; +export { DateTemplate } from "./DateTemplate"; +export { FormTemplate } from "./FormTemplate"; +export { EmailTemplate } from "./EmailTemplate"; +export { CouponTemplate } from "./CouponTemplate"; +export { LoadersTemplate } from "./LoadersTemplate"; +export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; -// Content templates (informational and display) -export * from "./content"; - -// Form templates (input and data collection) -export * from "./forms"; - -// Interactive templates (user choice and engagement) -export * from "./interactive"; - -// Layout components (base layouts and structural) -export * from "./layouts"; +// Layout Templates +export { TemplateLayout } from "./layouts/TemplateLayout"; diff --git a/src/components/funnel/templates/interactive/index.ts b/src/components/funnel/templates/interactive/index.ts deleted file mode 100644 index 82cbcfa..0000000 --- a/src/components/funnel/templates/interactive/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Interactive templates - user choice and engagement screens -export { ListTemplate } from "./ListTemplate"; -export { CouponTemplate } from "./CouponTemplate"; diff --git a/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx b/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx new file mode 100644 index 0000000..746d04b --- /dev/null +++ b/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx @@ -0,0 +1,241 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { TemplateLayout } from "./TemplateLayout"; +import { fn } from "storybook/test"; +import type { InfoScreenDefinition } from "@/lib/funnel/types"; + +// Создаем mock экран для демонстрации TemplateLayout +const mockScreen: InfoScreenDefinition = { + id: "template-layout-demo", + template: "info", + header: { + show: true, + showBackButton: true, + showProgress: true, + }, + title: { + text: "TemplateLayout Demo", + font: "manrope", + weight: "bold", + align: "center", + size: "2xl", + color: "default", + }, + description: { + text: "Это демонстрация **TemplateLayout** - централизованного layout wrapper для всех funnel templates. Он управляет header, progress bar, кнопкой назад и нижней кнопкой.", + font: "inter", + weight: "regular", + align: "center", + size: "md", + color: "default", + }, + bottomActionButton: { + show: true, + text: "Continue", + showGradientBlur: true, + }, + navigation: { + defaultNextScreenId: undefined, + rules: [], + }, +}; + +/** TemplateLayout - централизованный wrapper для всех funnel templates */ +const meta: Meta = { + title: "Funnel Templates/TemplateLayout", + component: TemplateLayout, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +TemplateLayout - это централизованный layout wrapper, который используется всеми funnel templates. + +**Основные возможности:** +- Управление header (показать/скрыть, progress bar, кнопка назад) +- Bottom action button с gradient blur +- Privacy terms consent +- Динамическая высота для фиксированного нижнего контента +- Единообразные настройки типографики + +**Архитектурные принципы:** +- Устраняет дублирование кода между templates +- Централизованная логика UI элементов +- Использует только существующие UI компоненты (LayoutQuestion, BottomActionButton) + ` + } + } + }, + args: { + screen: mockScreen, + canGoBack: true, + onBack: fn(), + screenProgress: { current: 5, total: 10 }, + titleDefaults: { font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }, + subtitleDefaults: { font: "inter", weight: "medium", color: "default", align: "center", size: "lg" }, + actionButtonOptions: { + defaultText: "Continue", + disabled: false, + onClick: fn(), + }, + children: ( +
+
+

+ Это контент, который передается в TemplateLayout через children prop. +

+
+
+ Header: управляется TemplateLayout +
+
+ Content: передается через children +
+
+ Button: управляется TemplateLayout +
+
+ Blur: управляется TemplateLayout +
+
+
+
+ ), + }, + argTypes: { + screen: { + control: { type: "object" }, + description: "Screen definition с настройками header и bottomActionButton" + }, + screenProgress: { + control: { type: "object" }, + description: "Прогресс для отображения в header" + }, + actionButtonOptions: { + control: { type: "object" }, + description: "Настройки нижней кнопки (если передается, кнопка показывается)" + }, + children: { + control: false, + description: "Контент template, который рендерится внутри layout" + }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный TemplateLayout со всеми элементами */ +export const Default: Story = {}; + +/** Layout без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Layout без progress bar */ +export const WithoutProgressBar: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: true, + showBackButton: true, + showProgress: false, + }, + }, + }, +}; + +/** Layout без кнопки назад */ +export const WithoutBackButton: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: true, + showBackButton: false, + showProgress: true, + }, + }, + canGoBack: false, + }, +}; + +/** Layout без нижней кнопки */ +export const WithoutActionButton: Story = { + args: { + actionButtonOptions: undefined, + }, +}; + +/** Layout только с blur gradient (без кнопки) */ +export const OnlyBlurGradient: Story = { + args: { + screen: { + ...mockScreen, + bottomActionButton: { + show: false, + showGradientBlur: true, + }, + }, + actionButtonOptions: undefined, + }, +}; + +/** Layout с Privacy Terms Consent */ +export const WithPrivacyConsent: Story = { + args: { + screen: { + ...mockScreen, + bottomActionButton: { + ...mockScreen.bottomActionButton, + showPrivacyTermsConsent: true, + }, + }, + }, +}; + +/** Layout с disabled кнопкой */ +export const WithDisabledButton: Story = { + args: { + actionButtonOptions: { + defaultText: "Continue", + disabled: true, + onClick: fn(), + }, + }, +}; + +/** Минимальный layout (только контент) */ +export const Minimal: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: false, + }, + bottomActionButton: { + show: false, + showGradientBlur: false, + }, + }, + actionButtonOptions: undefined, + children: ( +
+

Минимальный Layout

+

+ Только контент, без header и нижней кнопки +

+
+ ), + }, +}; diff --git a/src/components/funnel/templates/layouts/TemplateLayout.tsx b/src/components/funnel/templates/layouts/TemplateLayout.tsx index f904b15..7b7d29f 100644 --- a/src/components/funnel/templates/layouts/TemplateLayout.tsx +++ b/src/components/funnel/templates/layouts/TemplateLayout.tsx @@ -62,7 +62,6 @@ export function TemplateLayout({ }: TemplateLayoutProps) { // 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON const { - height: bottomActionButtonHeight, elementRef: bottomActionButtonRef, } = useDynamicSize({ defaultHeight: 132, @@ -115,10 +114,7 @@ export function TemplateLayout({ // 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ return ( -
+
{children} diff --git a/src/lib/admin/builder/state/defaults/blocks.ts b/src/lib/admin/builder/state/defaults/blocks.ts index fbf08c6..ba27a85 100644 --- a/src/lib/admin/builder/state/defaults/blocks.ts +++ b/src/lib/admin/builder/state/defaults/blocks.ts @@ -24,6 +24,7 @@ export function buildDefaultHeader(overrides?: Partial): Heade return { show: true, showBackButton: true, + showProgress: true, ...overrides, }; } diff --git a/src/lib/admin/builder/state/defaults/coupon.ts b/src/lib/admin/builder/state/defaults/coupon.ts index dc136e8..134b230 100644 --- a/src/lib/admin/builder/state/defaults/coupon.ts +++ b/src/lib/admin/builder/state/defaults/coupon.ts @@ -13,7 +13,9 @@ export function buildCouponDefaults(id: string): BuilderScreen { return { id, template: "coupon", - header: buildDefaultHeader(), + header: buildDefaultHeader({ + showProgress: false + }), title: buildDefaultTitle({ text: "Тебе повезло!", align: "center", diff --git a/src/lib/funnel/mappers.tsx b/src/lib/funnel/mappers.tsx index 8d0c755..e5b9143 100644 --- a/src/lib/funnel/mappers.tsx +++ b/src/lib/funnel/mappers.tsx @@ -110,6 +110,10 @@ export function shouldShowBackButton(header?: HeaderDefinition, canGoBack?: bool return Boolean(canGoBack); } +export function shouldShowProgress(header?: HeaderDefinition) { + return header?.showProgress !== false; +} + export function shouldShowHeader(header?: HeaderDefinition) { return header?.show !== false; } @@ -175,14 +179,17 @@ export function buildLayoutQuestionProps( const showBackButton = shouldShowBackButton(screen.header, canGoBack); const showHeader = shouldShowHeader(screen.header); + const showProgress = shouldShowProgress(screen.header); return { headerProps: showHeader ? { - progressProps: screenProgress ? buildHeaderProgress({ - current: screenProgress.current, - total: screenProgress.total, - label: `${screenProgress.current} of ${screenProgress.total}` - }) : buildHeaderProgress(screen.header?.progress), + progressProps: showProgress ? ( + screenProgress ? buildHeaderProgress({ + current: screenProgress.current, + total: screenProgress.total, + label: `${screenProgress.current} of ${screenProgress.total}` + }) : buildHeaderProgress(screen.header?.progress) + ) : undefined, onBack: showBackButton ? onBack : undefined, showBackButton, } : undefined, diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index 6e48c02..0acbe3b 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -39,6 +39,8 @@ export interface HeaderDefinition { progress?: HeaderProgressDefinition; /** Controls whether back button should be displayed. Defaults to true. */ showBackButton?: boolean; + /** Controls whether progress bar and label should be displayed. Defaults to true. */ + showProgress?: boolean; /** Controls whether header should be displayed at all. Defaults to true. */ show?: boolean; }