add ab 5$ trial grid and email validation

This commit is contained in:
dev.daminik00 2025-12-01 04:09:26 +03:00
parent a4587a37ea
commit 57d2148fd1
15 changed files with 1132 additions and 66 deletions

View File

@ -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"
},

View File

@ -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",

View File

@ -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 — its 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 arent 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 — its 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",

View File

@ -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);
});

View File

@ -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" && (
<TrialChoiceScreenConfig
screen={screen as BuilderScreen & { template: "trialChoice" }}
onUpdate={
onUpdate as (updates: Partial<TrialChoiceScreenDefinition>) => void
}
/>
)}
</div>
);
}

View File

@ -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<TrialChoiceScreenDefinition>) => 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 (
<div className="space-y-3">
<button
type="button"
onClick={handleToggle}
className="flex w-full items-center gap-2 text-left text-sm font-medium text-foreground hover:text-primary transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{title}
</button>
{isExpanded && <div className="ml-6 space-y-3">{children}</div>}
</div>
);
}
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 (
<div className="space-y-4">
<CollapsibleSection title="Подсказка со стрелкой" defaultExpanded={true}>
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/80 p-4 shadow-sm">
{/* Текст подсказки */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Текст подсказки
</label>
<TextAreaInput
value={arrowHint?.text ?? ""}
onChange={(event) => handleArrowHintTextChange(event.target.value)}
rows={3}
className="resize-y"
placeholder="Введите текст подсказки..."
/>
<div className="rounded-lg bg-muted/50 p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Доступные переменные:
</p>
<div className="flex flex-wrap gap-2">
<code className="bg-background px-2 py-1 rounded text-xs border border-border">
{"{{maxTrialPrice}}"}
</code>
<span className="text-xs text-muted-foreground">
максимальная цена триала
</span>
</div>
<p className="text-[10px] text-muted-foreground mt-2">
Переменная автоматически заменяется на форматированную цену самого дорогого варианта триала.
</p>
</div>
</div>
{/* Позиция стрелки */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Позиция стрелки
</label>
<select
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={arrowHint?.position ?? "bottom-right"}
onChange={(event) =>
handleArrowHintPositionChange(event.target.value as ArrowHintPosition)
}
>
{ARROW_POSITION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Определяет на какой угол сетки вариантов указывает стрелка.
При выборе позиции сверху стрелка и текст отображаются над сеткой.
</p>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Подсветка варианта" defaultExpanded={true}>
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/80 p-4 shadow-sm">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Подсвечиваемый вариант
</label>
<select
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={String(trialChoiceScreen.accentedOption ?? "server")}
onChange={(event) => {
const value = event.target.value;
const parsed = value === "server" ? "server" : parseInt(value, 10);
onUpdate({ accentedOption: parsed as AccentedOptionSetting });
}}
>
{ACCENTED_OPTION_OPTIONS.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Выберите какой вариант триала будет подсвечен.
Если выбранный номер больше количества вариантов с сервера,
будет использована серверная логика.
</p>
</div>
</div>
</CollapsibleSection>
</div>
);
}

View File

@ -6,3 +6,4 @@ export { ListScreenConfig } from "./ListScreenConfig";
export { TemplateConfig } from "./TemplateConfig";
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
export { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig";

View File

@ -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<string | null>(null);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -72,17 +73,35 @@ export function EmailTemplate({
form.setValue("email", selectedEmail || "");
}, [selectedEmail, form]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 ? <Spinner className="size-6" /> : undefined,
defaultText: defaultTexts?.nextButton || "Continue",
disabled: !isFormValid,
disabled: !hasEmail || isLoading,
onClick: handleContinue,
},
}
@ -116,21 +136,20 @@ export function EmailTemplate({
return (
<TemplateLayout {...layoutProps}>
<div className="w-full flex flex-col items-center gap-[26px]">
<TextInput
<EmailInput
label={screen.emailInput?.label || "Email"}
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
type="email"
value={selectedEmail}
onChange={handleChange}
onBlur={() => {
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 && (

View File

@ -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 (
<svg
width="15"
height="22"
viewBox="0 0 15 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(!pointsUp && "rotate-180")}
>
<g clipPath="url(#clip0_arrow)">
<path
d="M8.07009 0.292893C7.67956 -0.097631 7.0464 -0.097631 6.65587 0.292893L0.291916 6.65685C-0.0986075 7.04738 -0.0986075 7.68054 0.291916 8.07107C0.682441 8.46159 1.3156 8.46159 1.70613 8.07107L7.36298 2.41421L13.0198 8.07107C13.4104 8.46159 14.0435 8.46159 14.434 8.07107C14.8246 7.68054 14.8246 7.04738 14.434 6.65685L8.07009 0.292893ZM7.36298 22H8.36298V1H7.36298H6.36298V22H7.36298Z"
fill="#224E90"
/>
</g>
<defs>
<clipPath id="clip0_arrow">
<rect width="15" height="22" fill="white" />
</clipPath>
</defs>
</svg>
);
}
/**
* Получает 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.
</p>
{/* При позиции сверху: сначала текст + стрелка, потом сетка */}
{isTopPosition && (
<div className="flex flex-col gap-[9px] items-end w-full mt-[14px]">
{/* Текст над стрелочкой (при позиции сверху) */}
<p className="text-[#0244A5] font-sans text-[15px] font-semibold leading-[125%] w-full">
{arrowHintText}
</p>
{/* Контейнер стрелочки */}
<div className="relative h-[22px] w-full">
<div
className="absolute top-1/2"
style={getArrowPositionStyles(arrowHintPosition)}
>
<ArrowIcon pointsUp={false} />
</div>
</div>
</div>
)}
{/* TrialOptionsGrid с ref для прокрутки */}
<div ref={trialOptionsRef} className="mt-[14px]">
<TrialOptionsGrid
@ -180,45 +301,25 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
/>
</div>
{/* Блок со стрелочкой и текстом под вариантами */}
<div className="flex flex-col gap-[9px] items-end w-full mt-[14px]">
{/* Контейнер стрелочки */}
<div className="relative h-[22px] w-full">
{/* Стрелочка указывает на центр правой колонки (последний вариант) */}
<div
className="absolute top-1/2"
style={{
right: '25%', // Правая колонка = 50% ширины, ее центр = 25% от правого края
transform: 'translateY(-50%) translateX(50%)' // Центрируем саму стрелочку
}}
>
<svg
width="15"
height="22"
viewBox="0 0 15 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{/* При позиции снизу: сначала сетка, потом стрелка + текст */}
{!isTopPosition && (
<div className="flex flex-col gap-[9px] items-end w-full mt-[14px]">
{/* Контейнер стрелочки */}
<div className="relative h-[22px] w-full">
<div
className="absolute top-1/2"
style={getArrowPositionStyles(arrowHintPosition)}
>
<g clipPath="url(#clip0_570_2828)">
<path
d="M8.07009 0.292893C7.67956 -0.097631 7.0464 -0.097631 6.65587 0.292893L0.291916 6.65685C-0.0986075 7.04738 -0.0986075 7.68054 0.291916 8.07107C0.682441 8.46159 1.3156 8.46159 1.70613 8.07107L7.36298 2.41421L13.0198 8.07107C13.4104 8.46159 14.0435 8.46159 14.434 8.07107C14.8246 7.68054 14.8246 7.04738 14.434 6.65685L8.07009 0.292893ZM7.36298 22H8.36298V1H7.36298H6.36298V22H7.36298Z"
fill="#224E90"
/>
</g>
<defs>
<clipPath id="clip0_570_2828">
<rect width="15" height="22" fill="white"/>
</clipPath>
</defs>
</svg>
<ArrowIcon pointsUp={true} />
</div>
</div>
</div>
{/* Текст под стрелочкой */}
<p className="text-[#0244A5] font-sans text-[15px] font-semibold leading-[125%] w-full">
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.
</p>
</div>
{/* Текст под стрелочкой */}
<p className="text-[#0244A5] font-sans text-[15px] font-semibold leading-[125%] w-full">
{arrowHintText}
</p>
</div>
)}
</div>
</TemplateLayout>
);

View File

@ -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<React.ComponentProps<typeof Input>, "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<string[]>([]);
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<HTMLInputElement>) => {
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 (
<div
{...containerProps}
className={cn("w-full flex flex-col gap-2 relative", containerProps?.className)}
>
{label && (
<Label
htmlFor={inputId}
className="text-muted-foreground font-inter font-medium text-base"
>
{label}
</Label>
)}
<div className="relative">
<Input
data-slot="email-input"
type="email"
autoComplete="email"
className={cn(
"py-3.5 px-4",
"font-inter text-[18px]/[28px] font-semibold text-foreground",
"placeholder:text-muted-foreground placeholder:text-[18px]/[28px] font-medium",
"border-2 rounded-2xl",
error ? "border-destructive" : "border-primary/30",
className
)}
id={inputId}
value={value}
onChange={handleInputChange}
onBlur={handleBlur}
onFocus={handleFocus}
aria-invalid={!!error}
aria-errormessage={error || undefined}
{...props}
/>
{/* Autocomplete dropdown */}
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className={cn(
"w-full px-4 py-2.5 text-left text-sm font-inter",
"hover:bg-primary/10 active:bg-primary/20 transition-colors"
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSuggestionClick(suggestion)}
>
<span className="text-muted-foreground">{suggestion.split("@")[0]}@</span>
<span className="font-semibold text-foreground">{suggestion.split("@")[1]}</span>
</button>
))}
</div>
)}
</div>
{/* Error message */}
{error && (
<div className="flex flex-col gap-2">
<p className="text-destructive font-inter font-medium text-sm">
{error}
</p>
{/* Suggestion button */}
{suggestedEmail && onSuggestionAccept && (
<button
type="button"
onClick={handleAcceptSuggestion}
className={cn(
"w-full py-2.5 px-4 rounded-xl",
"bg-primary/10 hover:bg-primary/20 transition-colors",
"text-sm font-inter font-medium text-primary",
"flex items-center justify-center gap-2"
)}
>
<span>Use</span>
<span className="font-semibold">{suggestedEmail}</span>
<span>instead?</span>
</button>
)}
</div>
)}
</div>
);
}
export { EmailInput };

View File

@ -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<string | null>(null);
const [suggestedEmail, setSuggestedEmail] = useState<string | null>(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]
);
};

View File

@ -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>
): 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",
}),

View File

@ -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]);

View File

@ -285,7 +285,12 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"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<string, FunnelDefinition> = {
"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 — its 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 arent 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 — its 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",

View File

@ -557,12 +557,54 @@ export interface SpecialOfferScreenDefinition {
variants?: ScreenVariantDefinition<SpecialOfferScreenDefinition>[];
}
/**
* Позиция стрелки-подсказки относительно сетки вариантов триала.
* - 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<TrialChoiceScreenDefinition>[];