add data to auth and session

This commit is contained in:
dev.daminik00 2025-10-07 23:38:50 +02:00
parent d24795a4a6
commit b31b42a57d
15 changed files with 3671 additions and 2484 deletions

View File

@ -0,0 +1,354 @@
# Registration Field Key для List Single Selection
## Описание
Функциональность `registrationFieldKey` позволяет автоматически передавать выбранные значения из list single selection экранов в payload регистрации пользователя при авторизации через email экран.
## Как это работает
### 1. Настройка в админке
Для list экранов с `selectionType: "single"` в админке появляется дополнительное поле **"Ключ поля для регистрации"**.
В это поле можно указать путь к полю в объекте регистрации, используя точечную нотацию для вложенных объектов.
**Примеры:**
- `profile.gender``{ profile: { gender: "selected-id" } }`
- `profile.relationship_status``{ profile: { relationship_status: "selected-id" } }`
- `partner.gender``{ partner: { gender: "selected-id" } }`
### 2. Пример JSON конфигурации
```json
{
"id": "gender-screen",
"template": "list",
"title": {
"text": "What is your gender?"
},
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.gender",
"options": [
{
"id": "male",
"label": "Male",
"emoji": "👨"
},
{
"id": "female",
"label": "Female",
"emoji": "👩"
},
{
"id": "other",
"label": "Other",
"emoji": "🧑"
}
]
}
}
```
### 3. Как данные попадают в регистрацию и сессию
#### **Передача в сессию (на каждом экране):**
1. **Пользователь выбирает вариант** на экране (например, "Male" с id "male")
2. **Ответ сохраняется** в `FunnelAnswers` под ключом экрана: `{ "gender-screen": ["male"] }`
3. **При переходе вперед** (нажатие Continue или автопереход):
- Вызывается `buildSessionDataFromScreen()` для текущего экрана
- Создается объект с вложенной структурой: `{ profile: { gender: "male" } }`
- Вызывается `updateSession()` с данными:
```typescript
{
answers: { "gender-screen": ["male"] }, // Старая логика
profile: { gender: "male" } // Новая логика с registrationFieldKey
}
```
4. **Данные отправляются в API** и сохраняются в сессии пользователя
#### **Передача в регистрацию (при авторизации):**
1. **При переходе на email экран** вызывается функция `buildRegistrationDataFromAnswers()`
2. **Функция обрабатывает** все list single selection экраны с `registrationFieldKey`
3. **Создается объект** с вложенной структурой из всех экранов: `{ profile: { gender: "male", relationship_status: "single" } }`
4. **При авторизации** этот объект объединяется с базовым payload
5. **Отправляется на сервер** в составе `ICreateAuthorizeRequest`
### 4. Структура payload регистрации
**Базовый payload (без registrationFieldKey):**
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "funnel-id",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe"
}
```
**С registrationFieldKey (profile.gender = "male"):**
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "funnel-id",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe",
profile: {
gender: "male"
}
}
```
**С несколькими registrationFieldKey:**
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "funnel-id",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe",
profile: {
gender: "male",
relationship_status: "single"
},
partner: {
gender: "female"
}
}
```
## Полный пример воронки
```json
{
"meta": {
"id": "dating-funnel",
"title": "Dating Profile",
"firstScreenId": "gender"
},
"screens": [
{
"id": "gender",
"template": "list",
"title": { "text": "What is your gender?" },
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.gender",
"options": [
{ "id": "male", "label": "Male", "emoji": "👨" },
{ "id": "female", "label": "Female", "emoji": "👩" }
]
},
"navigation": {
"defaultNextScreenId": "relationship-status"
}
},
{
"id": "relationship-status",
"template": "list",
"title": { "text": "What is your relationship status?" },
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.relationship_status",
"options": [
{ "id": "single", "label": "Single" },
{ "id": "relationship", "label": "In a relationship" },
{ "id": "married", "label": "Married" }
]
},
"navigation": {
"defaultNextScreenId": "partner-gender"
}
},
{
"id": "partner-gender",
"template": "list",
"title": { "text": "What is your partner's gender?" },
"list": {
"selectionType": "single",
"registrationFieldKey": "partner.gender",
"options": [
{ "id": "male", "label": "Male", "emoji": "👨" },
{ "id": "female", "label": "Female", "emoji": "👩" }
]
},
"navigation": {
"defaultNextScreenId": "email"
}
},
{
"id": "email",
"template": "email",
"title": { "text": "Enter your email" },
"emailInput": {
"label": "Email",
"placeholder": "your@email.com"
}
}
]
}
```
**Результат после прохождения воронки:**
Если пользователь выбрал:
- Gender: Male
- Relationship Status: Single
- Partner Gender: Female
- Email: user@example.com
Payload регистрации будет:
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "dating-funnel",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe",
profile: {
gender: "male",
relationship_status: "single"
},
partner: {
gender: "female"
}
}
```
## Ограничения
1. **Только для single selection** - работает только с `selectionType: "single"`
2. **Только ID опции** - передается именно `id` выбранной опции, а не `label` или `value`
3. **Перезапись значений** - если несколько экранов используют один и тот же ключ, последний перезапишет предыдущий
4. **Обязательный email экран** - данные передаются только при авторизации через email экран
## Техническая реализация
### Файлы
- **types.ts** - добавлено поле `registrationFieldKey` в `ListScreenDefinition`
- **ListScreenConfig.tsx** - UI для настройки ключа в админке
- **registrationHelpers.ts** - утилиты `buildRegistrationDataFromAnswers()` и `buildSessionDataFromScreen()`
- **FunnelRuntime.tsx** - вызывает `buildSessionDataFromScreen()` при переходе вперед и передает в `updateSession()`
- **useAuth.ts** - принимает `registrationData` и объединяет с базовым payload
- **EmailTemplate.tsx** - вызывает `buildRegistrationDataFromAnswers()` и передает в `useAuth`
- **screenRenderer.tsx** - передает `answers` в `EmailTemplate`
### Функция buildRegistrationDataFromAnswers
Используется при авторизации для сбора данных со всех экранов воронки:
```typescript
export function buildRegistrationDataFromAnswers(
funnel: FunnelDefinition,
answers: FunnelAnswers
): RegistrationDataObject {
const registrationData: RegistrationDataObject = {};
for (const screen of funnel.screens) {
if (screen.template === "list") {
const listScreen = screen as ListScreenDefinition;
if (
listScreen.list.selectionType === "single" &&
listScreen.list.registrationFieldKey &&
answers[screen.id] &&
answers[screen.id].length > 0
) {
const selectedId = answers[screen.id][0];
const fieldKey = listScreen.list.registrationFieldKey;
// Устанавливаем значение по многоуровневому ключу
setNestedValue(registrationData, fieldKey, selectedId);
}
}
}
return registrationData;
}
```
### Функция buildSessionDataFromScreen
Используется при переходе вперед для сбора данных с текущего экрана:
```typescript
export function buildSessionDataFromScreen(
screen: { template: string; id: string; list?: { selectionType?: string; registrationFieldKey?: string } },
selectedIds: string[]
): RegistrationDataObject {
const sessionData: RegistrationDataObject = {};
if (screen.template === "list" && screen.list) {
const { selectionType, registrationFieldKey } = screen.list;
if (
selectionType === "single" &&
registrationFieldKey &&
selectedIds.length > 0
) {
const selectedId = selectedIds[0];
setNestedValue(sessionData, registrationFieldKey, selectedId);
}
}
return sessionData;
}
```
## Best Practices
1. **Используйте понятные ID** - ID опций должны соответствовать ожидаемым значениям на сервере
2. **Документируйте ключи** - ведите список используемых `registrationFieldKey` для избежания конфликтов
3. **Проверяйте типы** - убедитесь что ID опций соответствуют типам полей в `ICreateAuthorizeRequest`
4. **Тестируйте payload** - проверяйте что данные корректно попадают в регистрацию
## Примеры использования
### Простой профиль
```json
{
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.gender",
"options": [...]
}
}
```
### Вложенная структура
```json
{
"list": {
"selectionType": "single",
"registrationFieldKey": "partner.birthplace.country",
"options": [
{ "id": "US", "label": "United States" },
{ "id": "UK", "label": "United Kingdom" }
]
}
}
```
### Без регистрации (обычный list)
```json
{
"list": {
"selectionType": "single",
// registrationFieldKey не указан - данные не попадут в регистрацию
"options": [...]
}
}
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -174,6 +174,23 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
</label>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Регистрация пользователя</h4>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Ключ поля для регистрации (необязательно)
<TextInput
placeholder="Например: profile.birthdate"
value={dateScreen.dateInput?.registrationFieldKey ?? ""}
onChange={(event) => handleDateInputChange("registrationFieldKey", event.target.value || undefined)}
/>
</label>
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<p><strong>Использование:</strong> Выбранная дата будет передана в регистрацию и сессию по указанному ключу.</p>
<p className="mt-1"><strong>Формат даты:</strong> YYYY-MM-DD HH:mm (например: <code className="bg-muted px-1 rounded">2000-07-03 12:00</code>)</p>
<p className="mt-1"><strong>Пример:</strong> <code className="bg-muted px-1 rounded">profile.birthdate</code> <code className="bg-muted px-1 rounded">{`{ profile: { birthdate: "2000-07-03 12:00" } }`}</code></p>
</div>
</div>
</div>
);
}

View File

@ -48,6 +48,15 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
});
};
const handleRegistrationFieldKeyChange = (value: string) => {
onUpdate({
list: {
...listScreen.list,
registrationFieldKey: value || undefined,
},
});
};
const handleOptionChange = (
index: number,
@ -151,6 +160,24 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
</div>
</div>
{listScreen.list.selectionType === "single" && (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Регистрация пользователя</h4>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Ключ поля для регистрации (необязательно)
<TextInput
placeholder="Например: profile.gender"
value={listScreen.list.registrationFieldKey ?? ""}
onChange={(event) => handleRegistrationFieldKeyChange(event.target.value)}
/>
</label>
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<p><strong>Использование:</strong> Выбранный ID варианта будет передан в регистрацию пользователя по указанному ключу.</p>
<p className="mt-1"><strong>Пример:</strong> <code className="bg-muted px-1 rounded">profile.gender</code> <code className="bg-muted px-1 rounded">{`{ profile: { gender: "selected-id" } }`}</code></p>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Настройка вариантов</h4>

View File

@ -15,6 +15,7 @@ import type {
} from "@/lib/funnel/types";
import { getZodiacSign } from "@/lib/funnel/zodiac";
import { useSession } from "@/hooks/session/useSession";
import { buildSessionDataFromScreen } from "@/lib/funnel/registrationHelpers";
// Функция для оценки длины пути пользователя на основе текущих ответов
function estimatePathLength(
@ -128,10 +129,26 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const handleContinue = () => {
if (answers[currentScreen.id] && currentScreen.template !== "email") {
// Собираем данные для сессии
const sessionData = buildSessionDataFromScreen(
currentScreen,
answers[currentScreen.id]
);
// Для date экранов с registrationFieldKey НЕ отправляем answers
const shouldSkipAnswers =
currentScreen.template === "date" &&
"dateInput" in currentScreen &&
currentScreen.dateInput?.registrationFieldKey;
updateSession({
answers: {
[currentScreen.id]: answers[currentScreen.id],
},
...(shouldSkipAnswers ? {} : {
answers: {
[currentScreen.id]: answers[currentScreen.id],
},
}),
// Добавляем данные с registrationFieldKey
...sessionData,
});
}
const nextScreenId = resolveNextScreenId(
@ -217,10 +234,15 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
// Auto-advance for single selection without action button
if (shouldAutoAdvance) {
// Собираем данные для сессии
const sessionData = buildSessionDataFromScreen(currentScreen, ids);
updateSession({
answers: {
[currentScreen.id]: ids,
},
// Добавляем данные с registrationFieldKey если они есть
...sessionData,
});
const nextScreenId = resolveNextScreenId(
currentScreen,

View File

@ -23,8 +23,17 @@ const meta: Meta<typeof EmailTemplate> = {
screenProgress: { current: 9, total: 10 },
defaultTexts: {
nextButton: "Next",
},
funnel: {
meta: {
id: "test-funnel",
title: "Test Funnel",
},
screens: [],
},
selectedEmail: "",
onEmailChange: fn(),
answers: {},
},
argTypes: {
screen: {

View File

@ -8,7 +8,9 @@ import type {
EmailScreenDefinition,
DefaultTexts,
FunnelDefinition,
FunnelAnswers,
} from "@/lib/funnel/types";
import { buildRegistrationDataFromAnswers } from "@/lib/funnel/registrationHelpers";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import { zodResolver } from "@hookform/resolvers/zod";
@ -33,6 +35,7 @@ interface EmailTemplateProps {
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
answers: FunnelAnswers;
}
export function EmailTemplate({
@ -45,9 +48,14 @@ export function EmailTemplate({
onBack,
screenProgress,
defaultTexts,
answers,
}: EmailTemplateProps) {
// Собираем данные для регистрации из ответов воронки
const registrationData = buildRegistrationDataFromAnswers(funnel, answers);
const { authorization, isLoading, error } = useAuth({
funnelId: funnel?.meta?.id ?? "preview",
registrationData,
});
const [isTouched, setIsTouched] = useState(false);

View File

@ -50,7 +50,9 @@ export default function SelectInput({
"appearance-none",
"w-full min-w-[106px] h-fit! min-h-14",
"px-4 py-3.5",
"font-inter text-[18px]/[28px] font-medium text-placeholder-foreground",
"font-inter text-[18px]/[28px] font-medium",
// Цвет placeholder когда ничего не выбрано
props.value === "" || props.value === undefined ? "text-placeholder-foreground" : "text-foreground",
"rounded-2xl outline-2 outline-primary/30",
"duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",

View File

@ -13,9 +13,15 @@ const locale = "en";
interface IUseAuthProps {
funnelId: string;
/**
* Дополнительные данные для регистрации пользователя.
* Будут объединены с базовым payload при авторизации.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registrationData?: Record<string, any>;
}
export const useAuth = ({ funnelId }: IUseAuthProps) => {
export const useAuth = ({ funnelId, registrationData }: IUseAuthProps) => {
const { updateSession } = useSession({ funnelId });
const [isLoading, setIsLoading] = useState(false);
@ -35,35 +41,24 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
const getAuthorizationPayload = useCallback(
(email: string): ICreateAuthorizeRequest => {
const timezone = getClientTimezone();
return filterNullKeysOfObject<ICreateAuthorizeRequest>({
const basePayload = {
timezone,
locale,
email,
source: funnelId,
// source: "aura.compatibility.v2",
// profile: {
// name: username || "",
// gender: EGender[gender as keyof typeof EGender] || null,
// birthdate: formatDate(`${birthdate} ${birthtime}`),
// birthplace: {
// address: birthPlace,
// },
// },
// partner: {
// name: partnerName,
// gender: EGender[partnerGender as keyof typeof EGender] || null,
// birthdate: formatDate(partnerBirthdate),
// birthplace: {
// address: partnerBirthPlace,
// },
// },
sign: true,
signDate: new Date().toISOString(),
// feature: feature.includes("black") ? "ios" : feature,
feature: "stripe"
});
};
// Объединяем базовый payload с данными регистрации из воронки
const mergedPayload = registrationData
? { ...basePayload, ...registrationData }
: basePayload;
return filterNullKeysOfObject<ICreateAuthorizeRequest>(mergedPayload);
},
[funnelId]
[funnelId, registrationData]
);
const authorization = useCallback(

View File

@ -57,8 +57,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => {
feature: "stripe",
locale,
timezone,
// source: funnelId,
source: "aura.compatibility.v2",
source: funnelId,
sign: false,
utm,
domain: window.location.hostname,
@ -90,8 +89,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => {
sessionId: "",
};
}
}, [sessionId, timezone, setSessionId]);
// localStorageKey, sessionId, timezone, utm
}, [sessionId, timezone, setSessionId, funnelId]);
const updateSession = useCallback(
async (data: IUpdateSessionRequest["data"]) => {

View File

@ -6,9 +6,9 @@
import type { FunnelDefinition } from "./types";
export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"soulmate": {
"soulmate_prod": {
"meta": {
"id": "soulmate",
"id": "soulmate_prod",
"title": "Soulmate V1",
"description": "Soulmate",
"firstScreenId": "onboarding"
@ -2196,9 +2196,9 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "payment",
"isEndScreen": false,
"rules": []
"isEndScreen": false
},
"coupon": {
"title": {
@ -2690,5 +2690,305 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
}
}
]
},
"soulmate": {
"meta": {
"id": "soulmate",
"title": "Новая воронка",
"description": "Описание новой воронки",
"firstScreenId": "onboarding"
},
"defaultTexts": {
"nextButton": "Continue"
},
"screens": [
{
"id": "onboarding",
"template": "info",
"title": {
"text": "Добро пожаловать!",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "md",
"align": "center",
"color": "default"
},
"subtitle": {
"text": "Это ваша новая воронка. Начните редактирование.",
"show": true,
"font": "manrope",
"weight": "regular",
"size": "md",
"align": "center",
"color": "muted"
},
"navigation": {
"rules": [],
"defaultNextScreenId": "gender",
"isEndScreen": false
},
"icon": {
"type": "emoji",
"value": "🎯",
"size": "lg"
},
"variables": [],
"variants": []
},
{
"id": "gender",
"template": "list",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Новый экран",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "left",
"color": "default"
},
"subtitle": {
"text": "Добавьте детали справа",
"show": true,
"font": "manrope",
"weight": "medium",
"size": "lg",
"align": "left",
"color": "default"
},
"bottomActionButton": {
"show": false,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "example",
"isEndScreen": false
},
"list": {
"selectionType": "single",
"options": [
{
"id": "male",
"label": "male",
"disabled": false
},
{
"id": "female",
"label": "female",
"disabled": false
}
],
"registrationFieldKey": "profile.gender"
},
"variants": []
},
{
"id": "example",
"template": "list",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Новый экран",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "left",
"color": "default"
},
"subtitle": {
"text": "Добавьте детали справа",
"show": true,
"font": "manrope",
"weight": "medium",
"size": "lg",
"align": "left",
"color": "default"
},
"bottomActionButton": {
"show": true,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "birthdate",
"isEndScreen": false
},
"list": {
"selectionType": "single",
"options": [
{
"id": "option-1",
"label": "Вариант 1",
"disabled": false
},
{
"id": "option-2",
"label": "Вариант 2",
"disabled": false
}
]
},
"variants": []
},
{
"id": "birthdate",
"template": "date",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Новый экран",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "left",
"color": "default"
},
"subtitle": {
"text": "Добавьте детали справа",
"show": true,
"font": "manrope",
"weight": "medium",
"size": "lg",
"align": "left",
"color": "default"
},
"bottomActionButton": {
"show": true,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "email",
"isEndScreen": false
},
"dateInput": {
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"showSelectedDate": true,
"selectedDateFormat": "dd MMMM yyyy",
"selectedDateLabel": "Выбранная дата:",
"zodiac": {
"enabled": true,
"storageKey": "userZodiac"
},
"registrationFieldKey": "profile.birthdate"
},
"variants": []
},
{
"id": "email",
"template": "email",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Портрет твоей второй половинки готов! Куда нам его отправить?",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "center",
"color": "default"
},
"bottomActionButton": {
"show": true,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": true
},
"navigation": {
"rules": [],
"defaultNextScreenId": "final",
"isEndScreen": false
},
"emailInput": {
"label": "Email адрес",
"placeholder": "example@email.com"
},
"image": {
"src": "/female-portrait.jpg"
},
"variants": [
{
"conditions": [
{
"screenId": "gender",
"operator": "includesAny",
"optionIds": [
"male"
]
}
],
"overrides": {
"image": {
"src": "/male-portrait.jpg"
}
}
}
]
},
{
"id": "final",
"template": "info",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Спасибо за регистрацию",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "center",
"color": "default"
},
"subtitle": {
"text": "Добавьте подзаголовок для информационного экрана",
"show": true,
"font": "manrope",
"weight": "medium",
"size": "lg",
"align": "center",
"color": "default"
},
"bottomActionButton": {
"show": false,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"isEndScreen": true
},
"icon": {
"type": "emoji",
"value": "",
"size": "xl"
},
"variables": [],
"variants": []
}
]
}
};

View File

@ -0,0 +1,141 @@
import type { FunnelDefinition, FunnelAnswers, ListScreenDefinition, DateScreenDefinition } from "./types";
/**
* Тип для вложенных объектов регистрации
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RegistrationDataObject = Record<string, any>;
/**
* Устанавливает значение в объекте по многоуровневому ключу через точку.
* Например: setNestedValue({}, "profile.gender", "male") { profile: { gender: "male" } }
*/
function setNestedValue(obj: RegistrationDataObject, path: string, value: string): void {
const keys = path.split(".");
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key] || typeof current[key] !== "object") {
current[key] = {};
}
current = current[key] as RegistrationDataObject;
}
const lastKey = keys[keys.length - 1];
current[lastKey] = value;
}
/**
* Собирает данные для регистрации пользователя из ответов воронки.
* Обрабатывает list single selection и date экраны с registrationFieldKey.
*
* @param funnel - Определение воронки
* @param answers - Ответы пользователя
* @returns Объект с данными для регистрации
*/
export function buildRegistrationDataFromAnswers(
funnel: FunnelDefinition,
answers: FunnelAnswers
): RegistrationDataObject {
const registrationData: RegistrationDataObject = {};
// Проходим по всем экранам воронки
for (const screen of funnel.screens) {
// Обрабатываем list single selection экраны с registrationFieldKey
if (screen.template === "list") {
const listScreen = screen as ListScreenDefinition;
if (
listScreen.list.selectionType === "single" &&
listScreen.list.registrationFieldKey &&
answers[screen.id] &&
answers[screen.id].length > 0
) {
const selectedId = answers[screen.id][0];
const fieldKey = listScreen.list.registrationFieldKey;
setNestedValue(registrationData, fieldKey, selectedId);
}
}
// Обрабатываем date экраны с registrationFieldKey
if (screen.template === "date") {
const dateScreen = screen as DateScreenDefinition;
if (
dateScreen.dateInput?.registrationFieldKey &&
answers[screen.id] &&
answers[screen.id].length === 3
) {
const [month, day, year] = answers[screen.id];
const monthNum = parseInt(month);
const dayNum = parseInt(day);
const yearNum = parseInt(year);
// Проверяем что дата валидна
if (monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 && yearNum > 1900) {
// Форматируем в YYYY-MM-DD HH:mm с временем 12:00
const formattedDate = `${yearNum}-${monthNum.toString().padStart(2, '0')}-${dayNum.toString().padStart(2, '0')} 12:00`;
const fieldKey = dateScreen.dateInput.registrationFieldKey;
setNestedValue(registrationData, fieldKey, formattedDate);
}
}
}
}
return registrationData;
}
/**
* Собирает данные для сессии из одного экрана с registrationFieldKey.
* Используется при отправке данных в updateSession для конкретного экрана.
*
* @param screen - Определение экрана
* @param selectedIds - Выбранные значения пользователя на этом экране
* @returns Объект с данными для сессии (может быть пустым если нет registrationFieldKey)
*/
export function buildSessionDataFromScreen(
screen: {
template: string;
id: string;
list?: { selectionType?: string; registrationFieldKey?: string };
dateInput?: { registrationFieldKey?: string };
},
selectedIds: string[]
): RegistrationDataObject {
const sessionData: RegistrationDataObject = {};
// Обрабатываем list single selection экраны с registrationFieldKey
if (screen.template === "list" && screen.list) {
const { selectionType, registrationFieldKey } = screen.list;
if (
selectionType === "single" &&
registrationFieldKey &&
selectedIds.length > 0
) {
const selectedId = selectedIds[0];
setNestedValue(sessionData, registrationFieldKey, selectedId);
}
}
// Обрабатываем date экраны с registrationFieldKey
if (screen.template === "date" && screen.dateInput?.registrationFieldKey) {
// Для date экранов selectedIds = [month, day, year]
if (selectedIds.length === 3) {
const [month, day, year] = selectedIds;
const monthNum = parseInt(month);
const dayNum = parseInt(day);
const yearNum = parseInt(year);
// Проверяем что дата валидна
if (monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 && yearNum > 1900) {
// Форматируем в YYYY-MM-DD HH:mm с временем 12:00
const formattedDate = `${yearNum}-${monthNum.toString().padStart(2, '0')}-${dayNum.toString().padStart(2, '0')} 12:00`;
setNestedValue(sessionData, screen.dateInput.registrationFieldKey, formattedDate);
}
}
}
return sessionData;
}

View File

@ -233,6 +233,7 @@ const TEMPLATE_REGISTRY: Record<
screenProgress,
defaultTexts,
funnel,
answers,
}) => {
const emailScreen = screen as EmailScreenDefinition;
@ -254,6 +255,7 @@ const TEMPLATE_REGISTRY: Record<
screenProgress={screenProgress}
defaultTexts={defaultTexts}
funnel={funnel}
answers={answers}
/>
);
},

View File

@ -190,6 +190,13 @@ export interface DateInputDefinition {
enabled?: boolean;
storageKey?: string;
};
/**
* Ключ поля для регистрации пользователя и сессии.
* Поддерживает многоуровневую вложенность через точку.
* Дата передается в формате: "YYYY-MM-DD HH:mm" (время всегда 12:00)
* Например: "profile.birthdate" { profile: { birthdate: "2000-07-03 12:00" } }
*/
registrationFieldKey?: string;
}
@ -266,6 +273,12 @@ export interface ListScreenDefinition {
list: {
selectionType: SelectionType;
options: ListOptionDefinition[];
/**
* Ключ поля для регистрации пользователя (только для single selection).
* Поддерживает многоуровневую вложенность через точку.
* Например: "profile.gender" { profile: { gender: "selected-id" } }
*/
registrationFieldKey?: string;
};
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;