add ab 5$ trial grid and email validation
This commit is contained in:
parent
a4587a37ea
commit
57d2148fd1
@ -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"
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
165
scripts/migrate-trial-choice-arrow-hint.mjs
Normal file
165
scripts/migrate-trial-choice-arrow-hint.mjs
Normal 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);
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -6,3 +6,4 @@ export { ListScreenConfig } from "./ListScreenConfig";
|
||||
export { TemplateConfig } from "./TemplateConfig";
|
||||
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
|
||||
export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
|
||||
export { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig";
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
191
src/components/ui/EmailInput/EmailInput.tsx
Normal file
191
src/components/ui/EmailInput/EmailInput.tsx
Normal 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 };
|
||||
@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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 — 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",
|
||||
|
||||
@ -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>[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user