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",
|
"bake:funnels": "node scripts/bake-funnels.mjs",
|
||||||
"import:funnels": "node scripts/import-funnels-to-db.mjs",
|
"import:funnels": "node scripts/import-funnels-to-db.mjs",
|
||||||
"sync:funnels": "node scripts/sync-funnels-from-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",
|
"storybook": "storybook dev -p 6006 --ci",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -277,7 +277,12 @@
|
|||||||
"defaultNextScreenId": "payment",
|
"defaultNextScreenId": "payment",
|
||||||
"isEndScreen": false
|
"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",
|
"id": "payment",
|
||||||
|
|||||||
@ -2468,7 +2468,147 @@
|
|||||||
"defaultNextScreenId": "payment",
|
"defaultNextScreenId": "payment",
|
||||||
"isEndScreen": false
|
"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",
|
"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 { LoadersScreenConfig } from "./LoadersScreenConfig";
|
||||||
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
|
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
|
||||||
import { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
|
import { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
|
||||||
|
import { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig";
|
||||||
|
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
|
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
|
||||||
@ -31,6 +32,7 @@ import type {
|
|||||||
HeaderDefinition,
|
HeaderDefinition,
|
||||||
TrialPaymentScreenDefinition,
|
TrialPaymentScreenDefinition,
|
||||||
SpecialOfferScreenDefinition,
|
SpecialOfferScreenDefinition,
|
||||||
|
TrialChoiceScreenDefinition,
|
||||||
} from "@/lib/funnel/types";
|
} from "@/lib/funnel/types";
|
||||||
import { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
|
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>
|
</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 { TemplateConfig } from "./TemplateConfig";
|
||||||
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
|
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
|
||||||
export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
|
export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
|
||||||
|
export { TrialChoiceScreenConfig } from "./TrialChoiceScreenConfig";
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
|
import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
import { EmailInput } from "@/components/ui/EmailInput/EmailInput";
|
||||||
import type {
|
import type {
|
||||||
EmailScreenDefinition,
|
EmailScreenDefinition,
|
||||||
DefaultTexts,
|
DefaultTexts,
|
||||||
@ -53,13 +53,14 @@ export function EmailTemplate({
|
|||||||
// Собираем данные для регистрации из ответов воронки
|
// Собираем данные для регистрации из ответов воронки
|
||||||
const registrationData = buildRegistrationDataFromAnswers(funnel, answers);
|
const registrationData = buildRegistrationDataFromAnswers(funnel, answers);
|
||||||
|
|
||||||
const { authorization, isLoading, error } = useAuth({
|
const { authorization, isLoading, error, suggestedEmail, clearError } = useAuth({
|
||||||
funnelId: funnel?.meta?.id ?? "preview",
|
funnelId: funnel?.meta?.id ?? "preview",
|
||||||
googleAnalyticsId: funnel?.meta?.googleAnalyticsId,
|
googleAnalyticsId: funnel?.meta?.googleAnalyticsId,
|
||||||
registrationData,
|
registrationData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isTouched, setIsTouched] = useState(false);
|
const [isTouched, setIsTouched] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@ -72,17 +73,35 @@ export function EmailTemplate({
|
|||||||
form.setValue("email", selectedEmail || "");
|
form.setValue("email", selectedEmail || "");
|
||||||
}, [selectedEmail, form]);
|
}, [selectedEmail, form]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (value: string) => {
|
||||||
const value = e.target.value;
|
|
||||||
form.setValue("email", value);
|
form.setValue("email", value);
|
||||||
form.trigger("email");
|
form.trigger("email");
|
||||||
onEmailChange(value);
|
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 handleContinue = async () => {
|
||||||
const email = form.getValues("email");
|
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;
|
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(
|
const layoutProps = createTemplateLayoutProps(
|
||||||
screen,
|
screen,
|
||||||
@ -107,7 +127,7 @@ export function EmailTemplate({
|
|||||||
actionButton: {
|
actionButton: {
|
||||||
children: isLoading ? <Spinner className="size-6" /> : undefined,
|
children: isLoading ? <Spinner className="size-6" /> : undefined,
|
||||||
defaultText: defaultTexts?.nextButton || "Continue",
|
defaultText: defaultTexts?.nextButton || "Continue",
|
||||||
disabled: !isFormValid,
|
disabled: !hasEmail || isLoading,
|
||||||
onClick: handleContinue,
|
onClick: handleContinue,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -116,21 +136,20 @@ export function EmailTemplate({
|
|||||||
return (
|
return (
|
||||||
<TemplateLayout {...layoutProps}>
|
<TemplateLayout {...layoutProps}>
|
||||||
<div className="w-full flex flex-col items-center gap-[26px]">
|
<div className="w-full flex flex-col items-center gap-[26px]">
|
||||||
<TextInput
|
<EmailInput
|
||||||
label={screen.emailInput?.label || "Email"}
|
label={screen.emailInput?.label || "Email"}
|
||||||
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
|
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
|
||||||
type="email"
|
|
||||||
value={selectedEmail}
|
value={selectedEmail}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setIsTouched(true);
|
setIsTouched(true);
|
||||||
form.trigger("email");
|
form.trigger("email").then(() => {
|
||||||
|
setLocalError(form.formState.errors.email?.message || null);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
aria-invalid={(isTouched && !!form.formState.errors.email) || !!error}
|
error={displayError}
|
||||||
aria-errormessage={
|
suggestedEmail={suggestedEmail}
|
||||||
(isTouched ? form.formState.errors.email?.message : undefined) ||
|
onSuggestionAccept={handleSuggestionAccept}
|
||||||
(error ? "Something went wrong" : undefined)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{screen.image && (
|
{screen.image && (
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
DefaultTexts,
|
DefaultTexts,
|
||||||
FunnelAnswers,
|
FunnelAnswers,
|
||||||
FunnelDefinition,
|
FunnelDefinition,
|
||||||
|
ArrowHintPosition,
|
||||||
} from "@/lib/funnel/types";
|
} from "@/lib/funnel/types";
|
||||||
import { TemplateLayout } from "@/components/funnel/templates/layouts/TemplateLayout";
|
import { TemplateLayout } from "@/components/funnel/templates/layouts/TemplateLayout";
|
||||||
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
|
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
|
||||||
@ -16,6 +17,65 @@ import { useTrialVariantSelection } from "@/entities/session/payment/TrialVarian
|
|||||||
import { Currency } from "@/shared/types";
|
import { Currency } from "@/shared/types";
|
||||||
import { getFormattedPrice } from "@/shared/utils/price";
|
import { getFormattedPrice } from "@/shared/utils/price";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
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 {
|
interface TrialChoiceTemplateProps {
|
||||||
funnel: FunnelDefinition;
|
funnel: FunnelDefinition;
|
||||||
@ -60,7 +120,10 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
|||||||
const emailScreenId = emailScreen?.id || 'email'; // fallback на 'email' для обратной совместимости
|
const emailScreenId = emailScreen?.id || 'email'; // fallback на 'email' для обратной совместимости
|
||||||
const email = answers[emailScreenId]?.[0] || 'user@example.com';
|
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 items = useMemo(() => {
|
||||||
const currency = placement?.currency || Currency.USD;
|
const currency = placement?.currency || Currency.USD;
|
||||||
const variants = placement?.variants ?? [];
|
const variants = placement?.variants ?? [];
|
||||||
@ -78,20 +141,58 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const lastIndex = Math.max(0, list.length - 1);
|
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) => {
|
return list.map((it, index) => {
|
||||||
const variant = variants[index];
|
|
||||||
const accentFromApi = variant?.accent === true;
|
|
||||||
const state =
|
const state =
|
||||||
selectedId === it.id
|
selectedId === it.id
|
||||||
? "selected"
|
? "selected"
|
||||||
: accentFromApi || index === lastIndex
|
: index === accentIndex
|
||||||
? "accent"
|
? "accent"
|
||||||
: "default";
|
: "default";
|
||||||
// When user clicks disabled button, we flag all options with error until a selection is made
|
// When user clicks disabled button, we flag all options with error until a selection is made
|
||||||
const error = showError && selectedId == null;
|
const error = showError && selectedId == null;
|
||||||
return { ...it, state, error } as const;
|
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;
|
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.
|
Creating your portrait and guide costs us $13.67, but you can choose the amount that feels right to you.
|
||||||
</p>
|
</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 для прокрутки */}
|
{/* TrialOptionsGrid с ref для прокрутки */}
|
||||||
<div ref={trialOptionsRef} className="mt-[14px]">
|
<div ref={trialOptionsRef} className="mt-[14px]">
|
||||||
<TrialOptionsGrid
|
<TrialOptionsGrid
|
||||||
@ -180,45 +301,25 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Блок со стрелочкой и текстом под вариантами */}
|
{/* При позиции снизу: сначала сетка, потом стрелка + текст */}
|
||||||
<div className="flex flex-col gap-[9px] items-end w-full mt-[14px]">
|
{!isTopPosition && (
|
||||||
{/* Контейнер стрелочки */}
|
<div className="flex flex-col gap-[9px] items-end w-full mt-[14px]">
|
||||||
<div className="relative h-[22px] w-full">
|
{/* Контейнер стрелочки */}
|
||||||
{/* Стрелочка указывает на центр правой колонки (последний вариант) */}
|
<div className="relative h-[22px] w-full">
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2"
|
className="absolute top-1/2"
|
||||||
style={{
|
style={getArrowPositionStyles(arrowHintPosition)}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<g clipPath="url(#clip0_570_2828)">
|
<ArrowIcon pointsUp={true} />
|
||||||
<path
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Текст под стрелочкой */}
|
{/* Текст под стрелочкой */}
|
||||||
<p className="text-[#0244A5] font-sans text-[15px] font-semibold leading-[125%] w-full">
|
<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.
|
{arrowHintText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TemplateLayout>
|
</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 { setAuthTokenToCookie } from "@/entities/user/serverActions";
|
||||||
import analyticsService, { AnalyticsEvent, AnalyticsPlatform } from "@/services/analytics/analyticsService";
|
import analyticsService, { AnalyticsEvent, AnalyticsPlatform } from "@/services/analytics/analyticsService";
|
||||||
import { metricService } from "@/services/analytics/metricService";
|
import { metricService } from "@/services/analytics/metricService";
|
||||||
|
import { ApiError } from "@/shared/api/httpClient";
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
const locale = "en";
|
const locale = "en";
|
||||||
@ -29,6 +30,7 @@ export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseA
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [suggestedEmail, setSuggestedEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
const getAllCookies = useCallback(() => {
|
const getAllCookies = useCallback(() => {
|
||||||
// Токены которые не должны передаваться на backend
|
// Токены которые не должны передаваться на backend
|
||||||
@ -127,8 +129,30 @@ export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseA
|
|||||||
|
|
||||||
await setAuthTokenToCookie(token);
|
await setAuthTokenToCookie(token);
|
||||||
return token;
|
return token;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
setError((error as Error).message);
|
// 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -136,12 +160,19 @@ export const useAuth = ({ funnelId, googleAnalyticsId, registrationData }: IUseA
|
|||||||
[getAllCookies, getAuthorizationPayload, updateSession, funnelId]
|
[getAllCookies, getAuthorizationPayload, updateSession, funnelId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
setSuggestedEmail(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
authorization,
|
authorization,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
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 { BuilderScreen } from "@/lib/admin/builder/types";
|
||||||
|
import type { ArrowHintDefinition, AccentedOptionSetting } from "@/lib/funnel/types";
|
||||||
import {
|
import {
|
||||||
buildDefaultHeader,
|
buildDefaultHeader,
|
||||||
buildDefaultTitle,
|
buildDefaultTitle,
|
||||||
@ -7,6 +8,18 @@ import {
|
|||||||
buildDefaultNavigation,
|
buildDefaultNavigation,
|
||||||
} from "./blocks";
|
} 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 {
|
export function buildTrialChoiceDefaults(id: string): BuilderScreen {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -22,6 +35,8 @@ export function buildTrialChoiceDefaults(id: string): BuilderScreen {
|
|||||||
show: false,
|
show: false,
|
||||||
text: undefined,
|
text: undefined,
|
||||||
}),
|
}),
|
||||||
|
arrowHint: buildDefaultArrowHint(),
|
||||||
|
accentedOption: DEFAULT_ACCENTED_OPTION,
|
||||||
bottomActionButton: buildDefaultBottomActionButton({
|
bottomActionButton: buildDefaultBottomActionButton({
|
||||||
text: "Next",
|
text: "Next",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -75,7 +75,12 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
|||||||
return false;
|
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)) {
|
if (deepEqual(base, target)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -89,14 +94,16 @@ function diff(base: unknown, target: unknown): unknown {
|
|||||||
const keys = new Set([...Object.keys(target), ...Object.keys(base)]);
|
const keys = new Set([...Object.keys(target), ...Object.keys(base)]);
|
||||||
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
if (EXCLUDED_KEYS.has(key)) {
|
// EXCLUDED_KEYS применяем только на верхнем уровне экрана
|
||||||
|
if (isTopLevel && EXCLUDED_KEYS.has(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseValue = (base as AnyRecord)[key];
|
const baseValue = (base as AnyRecord)[key];
|
||||||
const targetValue = (target 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) {
|
if (nestedDiff !== undefined) {
|
||||||
entries.push([key, nestedDiff]);
|
entries.push([key, nestedDiff]);
|
||||||
|
|||||||
@ -285,7 +285,12 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
|||||||
"defaultNextScreenId": "payment",
|
"defaultNextScreenId": "payment",
|
||||||
"isEndScreen": false
|
"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",
|
"id": "payment",
|
||||||
@ -3318,7 +3323,147 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
|||||||
"defaultNextScreenId": "payment",
|
"defaultNextScreenId": "payment",
|
||||||
"isEndScreen": false
|
"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",
|
"id": "payment",
|
||||||
|
|||||||
@ -557,12 +557,54 @@ export interface SpecialOfferScreenDefinition {
|
|||||||
variants?: ScreenVariantDefinition<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 {
|
export interface TrialChoiceScreenDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
template: "trialChoice";
|
template: "trialChoice";
|
||||||
header?: HeaderDefinition;
|
header?: HeaderDefinition;
|
||||||
title: TitleDefinition;
|
title: TitleDefinition;
|
||||||
subtitle?: SubtitleDefinition;
|
subtitle?: SubtitleDefinition;
|
||||||
|
/**
|
||||||
|
* Настройки стрелки-подсказки под/над сеткой выбора триала.
|
||||||
|
* Стрелка указывает на определенный угол сетки с вариантами.
|
||||||
|
*/
|
||||||
|
arrowHint?: ArrowHintDefinition;
|
||||||
|
/**
|
||||||
|
* Какой вариант триала подсвечивать.
|
||||||
|
* По умолчанию: "server" (использовать accent из API).
|
||||||
|
*/
|
||||||
|
accentedOption?: AccentedOptionSetting;
|
||||||
bottomActionButton?: BottomActionButtonDefinition;
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
navigation?: NavigationDefinition;
|
navigation?: NavigationDefinition;
|
||||||
variants?: ScreenVariantDefinition<TrialChoiceScreenDefinition>[];
|
variants?: ScreenVariantDefinition<TrialChoiceScreenDefinition>[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user