add data to auth and session
This commit is contained in:
parent
d24795a4a6
commit
b31b42a57d
354
docs/REGISTRATION_FIELD_KEY.md
Normal file
354
docs/REGISTRATION_FIELD_KEY.md
Normal 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
2685
public/funnels/soulmate_prod.json
Normal file
2685
public/funnels/soulmate_prod.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"]) => {
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
141
src/lib/funnel/registrationHelpers.ts
Normal file
141
src/lib/funnel/registrationHelpers.ts
Normal 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;
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user