From 57d2148fd1823c135c26ebe51ab31a371dcc6c0d Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Mon, 1 Dec 2025 04:09:26 +0300 Subject: [PATCH] add ab 5$ trial grid and email validation --- package.json | 1 + public/funnels/soulmate-small.json | 7 +- public/funnels/soulmate.json | 142 ++++++++++++- scripts/migrate-trial-choice-arrow-hint.mjs | 165 +++++++++++++++ .../builder/templates/TemplateConfig.tsx | 10 + .../templates/TrialChoiceScreenConfig.tsx | 193 ++++++++++++++++++ .../admin/builder/templates/index.ts | 1 + .../templates/EmailTemplate/EmailTemplate.tsx | 49 +++-- .../TrialChoiceTemplate.tsx | 183 +++++++++++++---- src/components/ui/EmailInput/EmailInput.tsx | 191 +++++++++++++++++ src/hooks/auth/useAuth.ts | 37 +++- .../builder/state/defaults/trialChoice.ts | 15 ++ src/lib/admin/builder/variants.ts | 13 +- src/lib/funnel/bakedFunnels.ts | 149 +++++++++++++- src/lib/funnel/types.ts | 42 ++++ 15 files changed, 1132 insertions(+), 66 deletions(-) create mode 100644 scripts/migrate-trial-choice-arrow-hint.mjs create mode 100644 src/components/admin/builder/templates/TrialChoiceScreenConfig.tsx create mode 100644 src/components/ui/EmailInput/EmailInput.tsx diff --git a/package.json b/package.json index ba5017f..94a3db0 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "bake:funnels": "node scripts/bake-funnels.mjs", "import:funnels": "node scripts/import-funnels-to-db.mjs", "sync:funnels": "node scripts/sync-funnels-from-db.mjs", + "migrate:arrow-hint": "node scripts/migrate-trial-choice-arrow-hint.mjs", "storybook": "storybook dev -p 6006 --ci", "build-storybook": "storybook build" }, diff --git a/public/funnels/soulmate-small.json b/public/funnels/soulmate-small.json index 49782d1..ad4ac88 100644 --- a/public/funnels/soulmate-small.json +++ b/public/funnels/soulmate-small.json @@ -277,7 +277,12 @@ "defaultNextScreenId": "payment", "isEndScreen": false }, - "variants": [] + "variants": [], + "arrowHint": { + "text": "It costs us {{maxTrialPrice}} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.", + "position": "bottom-right" + }, + "accentedOption": "server" }, { "id": "payment", diff --git a/public/funnels/soulmate.json b/public/funnels/soulmate.json index a2b2b1c..7800ec3 100644 --- a/public/funnels/soulmate.json +++ b/public/funnels/soulmate.json @@ -2468,7 +2468,147 @@ "defaultNextScreenId": "payment", "isEndScreen": false }, - "variants": [] + "variants": [ + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "default" + ] + } + ], + "overrides": {} + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "support-over-1" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "Choosing more than $1 helps us pay the artists and specialists who make your portrait and personalized guide accurate and genuinely useful. Thank you for supporting the team.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "value-5-most-chosen" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "If your portrait and guide feel especially valuable to you, consider $5 — it’s the amount most often chosen by people who want to get the most out of a personalized analysis.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "quality-more-than-1" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "Most people choose $5 because they understand: quality work takes more than the minimum $1 contribution.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "1-vs-5-depth" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "$1 is often chosen by people who aren’t sure how detailed they want their result to be. If you want a more accurate portrait and a deeper breakdown, choose $5.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "5-coffee-analogy" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "Think of it this way: $5 is less than coffee and dessert, but it allows us to spend more time on your portrait and make the guide even more precise.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "5-detailed-insights" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "People who choose $5 receive a more detailed interpretation and expanded insights — it’s the choice for those who value quality and want the most accurate sketch and deeper recommendations in the guide.", + "position": "top-right" + }, + "accentedOption": 2 + } + } + ], + "arrowHint": { + "text": "It costs us {{maxTrialPrice}} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.", + "position": "bottom-right" + }, + "accentedOption": "server" }, { "id": "payment", diff --git a/scripts/migrate-trial-choice-arrow-hint.mjs b/scripts/migrate-trial-choice-arrow-hint.mjs new file mode 100644 index 0000000..4511bbd --- /dev/null +++ b/scripts/migrate-trial-choice-arrow-hint.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +/** + * Миграционный скрипт для добавления arrowHint и accentedOption настроек + * к существующим trialChoice экранам. + * + * Этот скрипт: + * 1. Подключается к MongoDB + * 2. Находит все воронки с экранами template: "trialChoice" + * 3. Добавляет дефолтные arrowHint и accentedOption настройки если их нет + * + * Запуск: + * node scripts/migrate-trial-choice-arrow-hint.mjs + * + * Опции: + * --dry-run Показать изменения без записи в базу + */ + +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config({ path: '.env.local' }); + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/witlab-funnel'; + +const isDryRun = process.argv.includes('--dry-run'); + +// Дефолтные значения для arrowHint +const DEFAULT_ARROW_HINT = { + text: "It costs us {{maxTrialPrice}} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.", + position: "bottom-right" +}; + +// Дефолтное значение для accentedOption +const DEFAULT_ACCENTED_OPTION = "server"; + +// Mongoose schema (minimal for migration) +const FunnelSchema = new mongoose.Schema({ + funnelData: mongoose.Schema.Types.Mixed, + name: String, + status: String, +}, { + timestamps: true, + collection: 'funnels' +}); + +const FunnelModel = mongoose.models.Funnel || mongoose.model('Funnel', FunnelSchema); + +async function connectToDatabase() { + try { + await mongoose.connect(MONGODB_URI, { + bufferCommands: false, + maxPoolSize: 10, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 45000, + }); + console.log('✅ Connected to MongoDB'); + } catch (error) { + console.error('❌ Failed to connect to MongoDB:', error.message); + process.exit(1); + } +} + +async function migrateFunnels() { + // Находим все воронки с trialChoice экранами без arrowHint + const funnels = await FunnelModel.find({ + 'funnelData.screens': { + $elemMatch: { + template: 'trialChoice' + } + } + }); + + console.log(`\n📋 Found ${funnels.length} funnels with trialChoice screens\n`); + + let updatedCount = 0; + let screensUpdated = 0; + + for (const funnel of funnels) { + const funnelId = funnel.funnelData?.meta?.id || funnel._id; + const funnelName = funnel.name || funnelId; + + let hasChanges = false; + const screens = funnel.funnelData?.screens || []; + + for (let i = 0; i < screens.length; i++) { + const screen = screens[i]; + + if (screen.template === 'trialChoice') { + const needsArrowHint = !screen.arrowHint; + const needsAccentedOption = screen.accentedOption === undefined; + + if (needsArrowHint || needsAccentedOption) { + const updates = []; + if (needsArrowHint) updates.push('arrowHint'); + if (needsAccentedOption) updates.push('accentedOption'); + + console.log(` 📍 [${funnelName}] Screen "${screen.id}" needs: ${updates.join(', ')}`); + + if (!isDryRun) { + screens[i] = { + ...screen, + ...(needsArrowHint && { arrowHint: DEFAULT_ARROW_HINT }), + ...(needsAccentedOption && { accentedOption: DEFAULT_ACCENTED_OPTION }), + }; + } + + hasChanges = true; + screensUpdated++; + } else { + console.log(` ✅ [${funnelName}] Screen "${screen.id}" already has all settings`); + } + } + } + + if (hasChanges) { + if (!isDryRun) { + funnel.funnelData.screens = screens; + funnel.markModified('funnelData'); + await funnel.save(); + console.log(` 💾 [${funnelName}] Saved changes`); + } else { + console.log(` 🔍 [${funnelName}] Would update (dry-run)`); + } + updatedCount++; + } + } + + return { updatedCount, screensUpdated, total: funnels.length }; +} + +async function main() { + console.log('🚀 Starting trialChoice arrowHint migration...'); + + if (isDryRun) { + console.log('⚠️ DRY RUN MODE - No changes will be saved\n'); + } + + await connectToDatabase(); + + const { updatedCount, screensUpdated, total } = await migrateFunnels(); + + console.log('\n📊 Migration Summary:'); + console.log('====================='); + console.log(`📁 Total funnels with trialChoice: ${total}`); + console.log(`📝 Funnels updated: ${updatedCount}`); + console.log(`🎯 Screens updated: ${screensUpdated}`); + + if (isDryRun) { + console.log('\n⚠️ This was a dry run. Run without --dry-run to apply changes.'); + } else if (updatedCount > 0) { + console.log('\n✅ Migration completed successfully!'); + } else { + console.log('\n✨ All trialChoice screens already have arrowHint configured!'); + } + + await mongoose.connection.close(); + console.log('\n👋 Database connection closed.'); +} + +main().catch(error => { + console.error('\n💥 Fatal error:', error); + process.exit(1); +}); diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 4ccd54f..96196c3 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -12,6 +12,7 @@ import { EmailScreenConfig } from "./EmailScreenConfig"; import { LoadersScreenConfig } from "./LoadersScreenConfig"; import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig"; import { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig"; +import { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig"; import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput"; @@ -31,6 +32,7 @@ import type { HeaderDefinition, TrialPaymentScreenDefinition, SpecialOfferScreenDefinition, + TrialChoiceScreenDefinition, } from "@/lib/funnel/types"; import { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig"; @@ -686,6 +688,14 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) { } /> )} + {template === "trialChoice" && ( + ) => void + } + /> + )} ); } diff --git a/src/components/admin/builder/templates/TrialChoiceScreenConfig.tsx b/src/components/admin/builder/templates/TrialChoiceScreenConfig.tsx new file mode 100644 index 0000000..8803940 --- /dev/null +++ b/src/components/admin/builder/templates/TrialChoiceScreenConfig.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput"; +import type { TrialChoiceScreenDefinition, ArrowHintPosition, AccentedOptionSetting } from "@/lib/funnel/types"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; + +interface TrialChoiceScreenConfigProps { + screen: BuilderScreen & { template: "trialChoice" }; + onUpdate: (updates: Partial) => void; +} + +const ARROW_POSITION_OPTIONS: { value: ArrowHintPosition; label: string }[] = [ + { value: "bottom-right", label: "Снизу справа" }, + { value: "bottom-left", label: "Снизу слева" }, + { value: "top-right", label: "Сверху справа" }, + { value: "top-left", label: "Сверху слева" }, +]; + +const ACCENTED_OPTION_OPTIONS: { value: AccentedOptionSetting; label: string }[] = [ + { value: "server", label: "С сервера (по умолчанию)" }, + { value: 1, label: "Вариант 1" }, + { value: 2, label: "Вариант 2" }, + { value: 3, label: "Вариант 3" }, + { value: 4, label: "Вариант 4" }, +]; + +function CollapsibleSection({ + title, + children, + defaultExpanded = false, +}: { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +}) { + const storageKey = `trial-choice-section-${title.toLowerCase().replace(/\s+/g, "-")}`; + + const [isExpanded, setIsExpanded] = useState(() => { + if (typeof window === "undefined") return defaultExpanded; + + const stored = sessionStorage.getItem(storageKey); + return stored !== null ? JSON.parse(stored) : defaultExpanded; + }); + + const handleToggle = () => { + const newExpanded = !isExpanded; + setIsExpanded(newExpanded); + + if (typeof window !== "undefined") { + sessionStorage.setItem(storageKey, JSON.stringify(newExpanded)); + } + }; + + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +} + +export function TrialChoiceScreenConfig({ + screen, + onUpdate, +}: TrialChoiceScreenConfigProps) { + const trialChoiceScreen = screen as TrialChoiceScreenDefinition; + const arrowHint = trialChoiceScreen.arrowHint; + + // Для корректной работы с вариантами, передаем полный объект arrowHint + // чтобы diff/merge функции корректно обрабатывали изменения + const handleArrowHintTextChange = (text: string) => { + onUpdate({ + arrowHint: { + text, + position: arrowHint?.position ?? "bottom-right", + }, + }); + }; + + const handleArrowHintPositionChange = (position: ArrowHintPosition) => { + onUpdate({ + arrowHint: { + text: arrowHint?.text ?? "", + position, + }, + }); + }; + + return ( +
+ +
+ {/* Текст подсказки */} +
+ + handleArrowHintTextChange(event.target.value)} + rows={3} + className="resize-y" + placeholder="Введите текст подсказки..." + /> +
+

+ Доступные переменные: +

+
+ + {"{{maxTrialPrice}}"} + + + — максимальная цена триала + +
+

+ Переменная автоматически заменяется на форматированную цену самого дорогого варианта триала. +

+
+
+ + {/* Позиция стрелки */} +
+ + +

+ Определяет на какой угол сетки вариантов указывает стрелка. + При выборе позиции сверху стрелка и текст отображаются над сеткой. +

+
+
+
+ + +
+
+ + +

+ Выберите какой вариант триала будет подсвечен. + Если выбранный номер больше количества вариантов с сервера, + будет использована серверная логика. +

+
+
+
+
+ ); +} diff --git a/src/components/admin/builder/templates/index.ts b/src/components/admin/builder/templates/index.ts index aa11882..b0a792b 100644 --- a/src/components/admin/builder/templates/index.ts +++ b/src/components/admin/builder/templates/index.ts @@ -6,3 +6,4 @@ export { ListScreenConfig } from "./ListScreenConfig"; export { TemplateConfig } from "./TemplateConfig"; export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig"; export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig"; +export { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig"; diff --git a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx index b553ed4..0f3bac6 100644 --- a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx +++ b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import Image from "next/image"; import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner"; -import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { EmailInput } from "@/components/ui/EmailInput/EmailInput"; import type { EmailScreenDefinition, DefaultTexts, @@ -53,13 +53,14 @@ export function EmailTemplate({ // Собираем данные для регистрации из ответов воронки const registrationData = buildRegistrationDataFromAnswers(funnel, answers); - const { authorization, isLoading, error } = useAuth({ + const { authorization, isLoading, error, suggestedEmail, clearError } = useAuth({ funnelId: funnel?.meta?.id ?? "preview", googleAnalyticsId: funnel?.meta?.googleAnalyticsId, registrationData, }); const [isTouched, setIsTouched] = useState(false); + const [localError, setLocalError] = useState(null); const form = useForm>({ resolver: zodResolver(formSchema), @@ -72,17 +73,35 @@ export function EmailTemplate({ form.setValue("email", selectedEmail || ""); }, [selectedEmail, form]); - const handleChange = (e: React.ChangeEvent) => { - const value = e.target.value; + const handleChange = (value: string) => { form.setValue("email", value); form.trigger("email"); onEmailChange(value); + // Clear errors when user types + clearError(); + setLocalError(null); + }; + + const handleSuggestionAccept = (email: string) => { + form.setValue("email", email); + form.trigger("email"); + onEmailChange(email); + clearError(); + setLocalError(null); }; const handleContinue = async () => { const email = form.getValues("email"); - if (!email || !form.formState.isValid || isLoading) { + // Validate form first + const isValid = await form.trigger("email"); + if (!isValid) { + setIsTouched(true); + setLocalError(form.formState.errors.email?.message || null); + return; + } + + if (!email || isLoading) { return; } @@ -96,7 +115,8 @@ export function EmailTemplate({ } }; - const isFormValid = form.formState.isValid && form.getValues("email"); + const hasEmail = !!form.getValues("email"); + const displayError = error || (isTouched ? localError : null); const layoutProps = createTemplateLayoutProps( screen, @@ -107,7 +127,7 @@ export function EmailTemplate({ actionButton: { children: isLoading ? : undefined, defaultText: defaultTexts?.nextButton || "Continue", - disabled: !isFormValid, + disabled: !hasEmail || isLoading, onClick: handleContinue, }, } @@ -116,21 +136,20 @@ export function EmailTemplate({ return (
- { setIsTouched(true); - form.trigger("email"); + form.trigger("email").then(() => { + setLocalError(form.formState.errors.email?.message || null); + }); }} - aria-invalid={(isTouched && !!form.formState.errors.email) || !!error} - aria-errormessage={ - (isTouched ? form.formState.errors.email?.message : undefined) || - (error ? "Something went wrong" : undefined) - } + error={displayError} + suggestedEmail={suggestedEmail} + onSuggestionAccept={handleSuggestionAccept} /> {screen.image && ( diff --git a/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx b/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx index 74ba17f..f2cab3a 100644 --- a/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx +++ b/src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx @@ -5,6 +5,7 @@ import type { DefaultTexts, FunnelAnswers, FunnelDefinition, + ArrowHintPosition, } from "@/lib/funnel/types"; import { TemplateLayout } from "@/components/funnel/templates/layouts/TemplateLayout"; import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers"; @@ -16,6 +17,65 @@ import { useTrialVariantSelection } from "@/entities/session/payment/TrialVarian import { Currency } from "@/shared/types"; import { getFormattedPrice } from "@/shared/utils/price"; import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; + +/** + * Компонент стрелки-указателя + * Направление стрелки зависит от того, находится ли она сверху или снизу + */ +function ArrowIcon({ pointsUp }: { pointsUp: boolean }) { + return ( + + + + + + + + + + + ); +} + +/** + * Получает CSS стили для позиционирования стрелки + */ +function getArrowPositionStyles(position: ArrowHintPosition): React.CSSProperties { + const isLeft = position.includes("left"); + + // Стрелка указывает на центр левой (25% слева) или правой (25% справа) колонки + if (isLeft) { + return { + left: "25%", + transform: "translateY(-50%) translateX(-50%)", + }; + } + return { + right: "25%", + transform: "translateY(-50%) translateX(50%)", + }; +} + +/** + * Заменяет переменные в тексте подсказки + */ +function substituteArrowHintVariables( + text: string, + maxTrialPrice: string +): string { + return text.replace(/\{\{maxTrialPrice\}\}/g, maxTrialPrice); +} interface TrialChoiceTemplateProps { funnel: FunnelDefinition; @@ -60,7 +120,10 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) { const emailScreenId = emailScreen?.id || 'email'; // fallback на 'email' для обратной совместимости const email = answers[emailScreenId]?.[0] || 'user@example.com'; - // Map variant -> TrialOption items with server-provided English titles and accent (last as fallback) + // Получаем настройку подсветки варианта из конфига экрана + const accentedOptionSetting = screen.accentedOption ?? "server"; + + // Map variant -> TrialOption items with server-provided English titles and accent const items = useMemo(() => { const currency = placement?.currency || Currency.USD; const variants = placement?.variants ?? []; @@ -78,20 +141,58 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) { ); const lastIndex = Math.max(0, list.length - 1); + + // Определяем какой индекс подсвечивать + // Если задан конкретный номер и он существует - используем его + // Иначе используем серверную логику + const accentIndex = (() => { + if (typeof accentedOptionSetting === "number") { + const targetIndex = accentedOptionSetting - 1; // 1-indexed -> 0-indexed + // Проверяем что индекс валиден для полученных вариантов + if (targetIndex >= 0 && targetIndex < list.length) { + return targetIndex; + } + // Fallback: если указанный вариант не существует, используем серверную логику + } + // Серверная логика: ищем accent: true или используем последний + const serverAccentIndex = variants.findIndex(v => v?.accent === true); + return serverAccentIndex >= 0 ? serverAccentIndex : lastIndex; + })(); + return list.map((it, index) => { - const variant = variants[index]; - const accentFromApi = variant?.accent === true; const state = selectedId === it.id ? "selected" - : accentFromApi || index === lastIndex + : index === accentIndex ? "accent" : "default"; // When user clicks disabled button, we flag all options with error until a selection is made const error = showError && selectedId == null; return { ...it, state, error } as const; }); - }, [placement, selectedId, showError]); + }, [placement, selectedId, showError, accentedOptionSetting]); + + // Вычисляем максимальную цену триала для переменной {{maxTrialPrice}} + const maxTrialPrice = useMemo(() => { + const currency = placement?.currency || Currency.USD; + const variants = placement?.variants ?? []; + + if (variants.length === 0) { + return getFormattedPrice(400, currency); // Fallback stub + } + + const maxPrice = Math.max(...variants.map(v => v?.trialPrice ?? 0)); + return getFormattedPrice(maxPrice, currency); + }, [placement]); + + // Получаем настройки стрелки-подсказки + const arrowHintPosition = screen.arrowHint?.position ?? "bottom-right"; + const arrowHintText = screen.arrowHint?.text + ? substituteArrowHintVariables(screen.arrowHint.text, maxTrialPrice) + : `It costs us ${maxTrialPrice} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.`; + + // Определяем, указывает ли стрелка вверх (top position) или вниз (bottom position) + const isTopPosition = arrowHintPosition.includes("top"); const isActionDisabled = selectedId == null; @@ -167,6 +268,26 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) { Creating your portrait and guide costs us $13.67, but you can choose the amount that feels right to you.

+ {/* При позиции сверху: сначала текст + стрелка, потом сетка */} + {isTopPosition && ( +
+ {/* Текст над стрелочкой (при позиции сверху) */} +

+ {arrowHintText} +

+ + {/* Контейнер стрелочки */} +
+
+ +
+
+
+ )} + {/* TrialOptionsGrid с ref для прокрутки */}
- {/* Блок со стрелочкой и текстом под вариантами */} -
- {/* Контейнер стрелочки */} -
- {/* Стрелочка указывает на центр правой колонки (последний вариант) */} -
- + {/* Контейнер стрелочки */} +
+
- - - - - - - - - + +
-
- {/* Текст под стрелочкой */} -

- It costs us $13.67 to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with. -

-
+ {/* Текст под стрелочкой */} +

+ {arrowHintText} +

+
+ )}
); diff --git a/src/components/ui/EmailInput/EmailInput.tsx b/src/components/ui/EmailInput/EmailInput.tsx new file mode 100644 index 0000000..18ecbd3 --- /dev/null +++ b/src/components/ui/EmailInput/EmailInput.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Input } from "../input"; +import { Label } from "../label"; +import { useId, useState, useEffect, useCallback } from "react"; + +// Popular email domains for autocomplete +const POPULAR_DOMAINS = [ + "gmail.com", + "yahoo.com", + "hotmail.com", + "outlook.com", + "icloud.com", + "protonmail.com", + "aol.com", + "live.com", +]; + +interface EmailInputProps extends Omit, "onChange"> { + label?: string; + containerProps?: React.ComponentProps<"div">; + value: string; + onChange: (value: string) => void; + error?: string | null; + suggestedEmail?: string | null; + onSuggestionAccept?: (email: string) => void; +} + +function EmailInput({ + className, + label, + containerProps, + value, + onChange, + error, + suggestedEmail, + onSuggestionAccept, + ...props +}: EmailInputProps) { + const id = useId(); + const inputId = props.id || id; + + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + + // Generate domain suggestions based on input + const generateSuggestions = useCallback((input: string): string[] => { + if (!input || !input.includes("@")) return []; + + const [localPart, domainPart] = input.split("@"); + if (!localPart) return []; + + // If domain part is empty or partial, suggest completions + if (!domainPart) { + return POPULAR_DOMAINS.slice(0, 5).map(domain => `${localPart}@${domain}`); + } + + // Filter domains that start with what user typed + const matchingDomains = POPULAR_DOMAINS.filter(domain => + domain.toLowerCase().startsWith(domainPart.toLowerCase()) && domain !== domainPart + ); + + return matchingDomains.slice(0, 5).map(domain => `${localPart}@${domain}`); + }, []); + + // Update suggestions when value changes + useEffect(() => { + const newSuggestions = generateSuggestions(value); + setSuggestions(newSuggestions); + }, [value, generateSuggestions]); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + onChange(newValue); + setShowSuggestions(true); + }; + + const handleSuggestionClick = (suggestion: string) => { + onChange(suggestion); + setShowSuggestions(false); + setSuggestions([]); + }; + + const handleBlur = () => { + // Delay to allow click on suggestion + setTimeout(() => setShowSuggestions(false), 150); + }; + + const handleFocus = () => { + if (suggestions.length > 0) { + setShowSuggestions(true); + } + }; + + const handleAcceptSuggestion = () => { + if (suggestedEmail && onSuggestionAccept) { + onSuggestionAccept(suggestedEmail); + } + }; + + return ( +
+ {label && ( + + )} + +
+ + + {/* Autocomplete dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => ( + + ))} +
+ )} +
+ + {/* Error message */} + {error && ( +
+

+ {error} +

+ + {/* Suggestion button */} + {suggestedEmail && onSuggestionAccept && ( + + )} +
+ )} +
+ ); +} + +export { EmailInput }; diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 02aa06b..2bbfa7d 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -9,6 +9,7 @@ import { createAuthorization } from "@/entities/user/actions"; import { setAuthTokenToCookie } from "@/entities/user/serverActions"; import analyticsService, { AnalyticsEvent, AnalyticsPlatform } from "@/services/analytics/analyticsService"; import { metricService } from "@/services/analytics/metricService"; +import { ApiError } from "@/shared/api/httpClient"; // TODO const locale = "en"; @@ -29,6 +30,7 @@ export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseA const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [suggestedEmail, setSuggestedEmail] = useState(null); const getAllCookies = useCallback(() => { // Токены которые не должны передаваться на backend @@ -127,8 +129,30 @@ export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseA await setAuthTokenToCookie(token); return token; - } catch (error) { - setError((error as Error).message); + } catch (err) { + // Extract error message and suggestion from API error + if (err instanceof ApiError && err.data) { + const errorData = err.data as { errors?: Array<{ msg: string; path: string }> }; + const emailError = errorData.errors?.find(e => e.path === 'email'); + + if (emailError) { + setError(emailError.msg); + + // Extract suggested email from "Did you mean user@gmail.com?" message + const suggestionMatch = emailError.msg.match(/Did you mean (.+)\?/); + if (suggestionMatch) { + setSuggestedEmail(suggestionMatch[1]); + } else { + setSuggestedEmail(null); + } + } else { + setError('Email validation failed'); + setSuggestedEmail(null); + } + } else { + setError((err as Error).message); + setSuggestedEmail(null); + } } finally { setIsLoading(false); } @@ -136,12 +160,19 @@ export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseA [getAllCookies, getAuthorizationPayload, updateSession, funnelId] ); + const clearError = useCallback(() => { + setError(null); + setSuggestedEmail(null); + }, []); + return useMemo( () => ({ authorization, isLoading, error, + suggestedEmail, + clearError, }), - [authorization, isLoading, error] + [authorization, isLoading, error, suggestedEmail, clearError] ); }; diff --git a/src/lib/admin/builder/state/defaults/trialChoice.ts b/src/lib/admin/builder/state/defaults/trialChoice.ts index 6a8c39c..a4004c8 100644 --- a/src/lib/admin/builder/state/defaults/trialChoice.ts +++ b/src/lib/admin/builder/state/defaults/trialChoice.ts @@ -1,4 +1,5 @@ import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { ArrowHintDefinition, AccentedOptionSetting } from "@/lib/funnel/types"; import { buildDefaultHeader, buildDefaultTitle, @@ -7,6 +8,18 @@ import { buildDefaultNavigation, } from "./blocks"; +export function buildDefaultArrowHint( + overrides?: Partial +): ArrowHintDefinition { + return { + text: "It costs us {{maxTrialPrice}} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.", + position: "bottom-right", + ...overrides, + }; +} + +export const DEFAULT_ACCENTED_OPTION: AccentedOptionSetting = "server"; + export function buildTrialChoiceDefaults(id: string): BuilderScreen { return { id, @@ -22,6 +35,8 @@ export function buildTrialChoiceDefaults(id: string): BuilderScreen { show: false, text: undefined, }), + arrowHint: buildDefaultArrowHint(), + accentedOption: DEFAULT_ACCENTED_OPTION, bottomActionButton: buildDefaultBottomActionButton({ text: "Next", }), diff --git a/src/lib/admin/builder/variants.ts b/src/lib/admin/builder/variants.ts index ae5c012..36cd209 100644 --- a/src/lib/admin/builder/variants.ts +++ b/src/lib/admin/builder/variants.ts @@ -75,7 +75,12 @@ function deepEqual(a: unknown, b: unknown): boolean { return false; } -function diff(base: unknown, target: unknown): unknown { +/** + * Рекурсивно вычисляет разницу между base и target. + * EXCLUDED_KEYS применяются только на верхнем уровне (isTopLevel = true), + * чтобы не исключать поля вроде "position" во вложенных объектах (например, arrowHint.position). + */ +function diff(base: unknown, target: unknown, isTopLevel = true): unknown { if (deepEqual(base, target)) { return undefined; } @@ -89,14 +94,16 @@ function diff(base: unknown, target: unknown): unknown { const keys = new Set([...Object.keys(target), ...Object.keys(base)]); keys.forEach((key) => { - if (EXCLUDED_KEYS.has(key)) { + // EXCLUDED_KEYS применяем только на верхнем уровне экрана + if (isTopLevel && EXCLUDED_KEYS.has(key)) { return; } const baseValue = (base as AnyRecord)[key]; const targetValue = (target as AnyRecord)[key]; - const nestedDiff = diff(baseValue, targetValue); + // Рекурсивный вызов с isTopLevel = false + const nestedDiff = diff(baseValue, targetValue, false); if (nestedDiff !== undefined) { entries.push([key, nestedDiff]); diff --git a/src/lib/funnel/bakedFunnels.ts b/src/lib/funnel/bakedFunnels.ts index 3d20af9..2d67997 100644 --- a/src/lib/funnel/bakedFunnels.ts +++ b/src/lib/funnel/bakedFunnels.ts @@ -285,7 +285,12 @@ export const BAKED_FUNNELS: Record = { "defaultNextScreenId": "payment", "isEndScreen": false }, - "variants": [] + "variants": [], + "arrowHint": { + "text": "It costs us {{maxTrialPrice}} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.", + "position": "bottom-right" + }, + "accentedOption": "server" }, { "id": "payment", @@ -3318,7 +3323,147 @@ export const BAKED_FUNNELS: Record = { "defaultNextScreenId": "payment", "isEndScreen": false }, - "variants": [] + "variants": [ + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "default" + ] + } + ], + "overrides": {} + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "support-over-1" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "Choosing more than $1 helps us pay the artists and specialists who make your portrait and personalized guide accurate and genuinely useful. Thank you for supporting the team.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "value-5-most-chosen" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "If your portrait and guide feel especially valuable to you, consider $5 — it’s the amount most often chosen by people who want to get the most out of a personalized analysis.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "quality-more-than-1" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "Most people choose $5 because they understand: quality work takes more than the minimum $1 contribution.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "1-vs-5-depth" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "$1 is often chosen by people who aren’t sure how detailed they want their result to be. If you want a more accurate portrait and a deeper breakdown, choose $5.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "5-coffee-analogy" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "Think of it this way: $5 is less than coffee and dessert, but it allows us to spend more time on your portrait and make the guide even more precise.", + "position": "top-right" + }, + "accentedOption": 2 + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "conditionType": "unleash", + "unleashFlag": "soulmate-trial-5", + "unleashVariants": [ + "5-detailed-insights" + ] + } + ], + "overrides": { + "arrowHint": { + "text": "People who choose $5 receive a more detailed interpretation and expanded insights — it’s the choice for those who value quality and want the most accurate sketch and deeper recommendations in the guide.", + "position": "top-right" + }, + "accentedOption": 2 + } + } + ], + "arrowHint": { + "text": "It costs us {{maxTrialPrice}} to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.", + "position": "bottom-right" + }, + "accentedOption": "server" }, { "id": "payment", diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index 18b13cb..22f1469 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -557,12 +557,54 @@ export interface SpecialOfferScreenDefinition { variants?: ScreenVariantDefinition[]; } +/** + * Позиция стрелки-подсказки относительно сетки вариантов триала. + * - bottom-right: стрелка снизу, указывает на правый нижний элемент (default) + * - bottom-left: стрелка снизу, указывает на левый нижний элемент + * - top-right: стрелка сверху, указывает на правый верхний элемент + * - top-left: стрелка сверху, указывает на левый верхний элемент + */ +export type ArrowHintPosition = "bottom-left" | "bottom-right" | "top-left" | "top-right"; + +export interface ArrowHintDefinition { + /** + * Текст подсказки рядом со стрелкой. + * Поддерживает переменную {{maxTrialPrice}} для вставки максимальной цены триала. + */ + text?: string; + /** + * Позиция стрелки относительно сетки вариантов. + * По умолчанию: "bottom-right" + */ + position?: ArrowHintPosition; +} + +/** + * Какой вариант триала подсвечивать (accent). + * - "server": использовать значение accent из ответа сервера (по умолчанию) + * - 1, 2, 3, 4: номер варианта для подсветки (1-indexed) + * + * Если выбранный номер превышает количество вариантов с сервера, + * автоматически используется серверная логика. + */ +export type AccentedOptionSetting = "server" | 1 | 2 | 3 | 4; + export interface TrialChoiceScreenDefinition { id: string; template: "trialChoice"; header?: HeaderDefinition; title: TitleDefinition; subtitle?: SubtitleDefinition; + /** + * Настройки стрелки-подсказки под/над сеткой выбора триала. + * Стрелка указывает на определенный угол сетки с вариантами. + */ + arrowHint?: ArrowHintDefinition; + /** + * Какой вариант триала подсвечивать. + * По умолчанию: "server" (использовать accent из API). + */ + accentedOption?: AccentedOptionSetting; bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; variants?: ScreenVariantDefinition[];