commit
2e1bca8466
385
AB_TESTING_GUIDE.md
Normal file
385
AB_TESTING_GUIDE.md
Normal file
@ -0,0 +1,385 @@
|
||||
# 🧪 AB Testing с Unleash в Воронках
|
||||
|
||||
Полное руководство по интеграции AB тестов через Unleash feature flags в систему воронок.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Настройка](#настройка)
|
||||
- [Примеры использования](#примеры-использования)
|
||||
- [API Reference](#api-reference)
|
||||
- [Архитектура](#архитектура)
|
||||
|
||||
## 🔧 Настройка
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Добавьте в `.env.local`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UNLEASH_URL=https://your-unleash-instance.com/api/frontend
|
||||
NEXT_PUBLIC_UNLEASH_CLIENT_KEY=your-client-key
|
||||
```
|
||||
|
||||
**Важно:** Если переменные не установлены, AB тесты будут отключены автоматически.
|
||||
|
||||
### 2. Настройка Unleash Dashboard
|
||||
|
||||
1. Создайте feature flag в Unleash (например, `trial-button-test`)
|
||||
2. Добавьте варианты (v0, v1, v2, etc.)
|
||||
3. Настройте stickiness на `sessionId` для консистентности
|
||||
4. Добавьте gradual rollout strategy
|
||||
|
||||
## 💡 Примеры использования
|
||||
|
||||
### Пример 1: AB тест текста кнопки
|
||||
|
||||
**Сценарий:** Тестируем разные тексты кнопки оплаты
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "payment-screen",
|
||||
"template": "trialPayment",
|
||||
"bottomActionButton": {
|
||||
"text": "Continue"
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "payment-screen",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "trial-button-test",
|
||||
"unleashVariants": ["v1"],
|
||||
"operator": "includesAny"
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"bottomActionButton": {
|
||||
"text": "Start 3-Day Trial"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "payment-screen",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "trial-button-test",
|
||||
"unleashVariants": ["v2"],
|
||||
"operator": "includesAny"
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"bottomActionButton": {
|
||||
"text": "Get My Reading Now"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- v0 (контроль): "Continue"
|
||||
- v1: "Start 3-Day Trial"
|
||||
- v2: "Get My Reading Now"
|
||||
|
||||
### Пример 2: AB тест навигации
|
||||
|
||||
**Сценарий:** Тестируем короткий vs длинный onboarding
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gender-select",
|
||||
"template": "list",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "birthdate",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender-select",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "onboarding-flow-test",
|
||||
"unleashVariants": ["short"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "payment"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender-select",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "onboarding-flow-test",
|
||||
"unleashVariants": ["long"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "relationship-status"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- v0/default: gender → birthdate
|
||||
- short: gender → payment (пропускаем шаги)
|
||||
- long: gender → relationship-status → ... → payment
|
||||
|
||||
### Пример 3: Комбинированные условия
|
||||
|
||||
**Сценарий:** AB тест только для женской аудитории
|
||||
|
||||
```json
|
||||
{
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["female"],
|
||||
"operator": "includesAny"
|
||||
},
|
||||
{
|
||||
"screenId": "payment",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "female-specific-test",
|
||||
"unleashVariants": ["v1"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Special offer for you! ✨"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### NavigationConditionDefinition
|
||||
|
||||
```typescript
|
||||
interface NavigationConditionDefinition {
|
||||
screenId: string;
|
||||
conditionType?: "options" | "values" | "unleash";
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly" | "equals";
|
||||
|
||||
// Для Unleash AB тестов
|
||||
unleashFlag?: string;
|
||||
unleashVariants?: string[];
|
||||
|
||||
// Для других типов
|
||||
optionIds?: string[];
|
||||
values?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Операторы
|
||||
|
||||
| Оператор | Описание | Пример |
|
||||
|----------|----------|--------|
|
||||
| `includesAny` | Хотя бы один вариант совпадает | `["v1", "v2"]` → true если v1 ИЛИ v2 |
|
||||
| `includesAll` | Все варианты совпадают | `["v1"]` → true если v1 (для одного = includesAny) |
|
||||
| `includesExactly` | Только указанные варианты | Редко используется |
|
||||
| `equals` | Точное совпадение | `["v1"]` → true только если v1 |
|
||||
|
||||
### Типы условий
|
||||
|
||||
| Тип | Использование | Пример |
|
||||
|-----|---------------|--------|
|
||||
| `options` | List экраны | `{ conditionType: "options", optionIds: ["male"] }` |
|
||||
| `values` | Зодиак, возраст | `{ conditionType: "values", values: ["aries", "leo"] }` |
|
||||
| `unleash` | AB тесты | `{ conditionType: "unleash", unleashFlag: "test-1" }` |
|
||||
|
||||
## 🏗 Архитектура
|
||||
|
||||
### Компоненты
|
||||
|
||||
```
|
||||
UnleashProvider (от @unleash/proxy-client-react)
|
||||
└── FunnelUnleashWrapper (собирает все флаги из воронки)
|
||||
└── UnleashContextProvider (предоставляет активные варианты)
|
||||
└── FunnelRuntime (использует варианты для navigation/variants)
|
||||
```
|
||||
|
||||
### Поток данных
|
||||
|
||||
1. **FunnelUnleashWrapper** сканирует все экраны воронки
|
||||
2. Находит все `unleashFlag` в conditions
|
||||
3. Получает варианты для всех флагов через `useVariant`
|
||||
4. Передает `{ flagName: variant }` в **UnleashContextProvider**
|
||||
5. **FunnelRuntime** использует `unleashChecker` для проверки условий
|
||||
6. **navigation.ts** и **variants.ts** вызывают `unleashChecker` при проверке
|
||||
|
||||
### Файловая структура
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/funnel/
|
||||
│ ├── unleash/
|
||||
│ │ ├── UnleashProvider.tsx # Wrapper для Unleash SDK
|
||||
│ │ ├── UnleashContext.tsx # Context с активными вариантами
|
||||
│ │ ├── useUnleash.ts # Hook для получения вариантов
|
||||
│ │ └── index.ts
|
||||
│ ├── navigation.ts # Поддержка unleash в условиях
|
||||
│ ├── variants.ts # Поддержка unleash в вариантах
|
||||
│ └── types.ts # Расширенные типы
|
||||
├── components/
|
||||
│ ├── funnel/
|
||||
│ │ ├── FunnelUnleashWrapper.tsx # Сборщик флагов
|
||||
│ │ └── FunnelRuntime.tsx # Интеграция unleashChecker
|
||||
│ └── admin/builder/forms/
|
||||
│ ├── UnleashConditionEditor.tsx # UI для настройки
|
||||
│ ├── ConditionTypeSelector.tsx # Выбор типа
|
||||
│ └── variants/
|
||||
│ └── VariantConditionEditor.tsx # Интеграция в существующий UI
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### 1. Naming Convention
|
||||
|
||||
```bash
|
||||
# Формат: [page]-[element]-[description]
|
||||
trial-button-text
|
||||
onboarding-flow-length
|
||||
payment-copy-variant
|
||||
```
|
||||
|
||||
### 2. Variants Naming
|
||||
|
||||
```bash
|
||||
# Всегда используйте v0 как контроль
|
||||
v0 - контрольная группа (baseline)
|
||||
v1 - вариант 1
|
||||
v2 - вариант 2
|
||||
```
|
||||
|
||||
### 3. Stickiness
|
||||
|
||||
**Всегда используйте `sessionId` для stickiness** в Unleash:
|
||||
- Обеспечивает консистентность для анонимных пользователей
|
||||
- Пользователь видит одинаковый вариант на всех экранах
|
||||
- Не требует авторизации
|
||||
|
||||
### 4. Rollout Strategy
|
||||
|
||||
```bash
|
||||
# Начните с малого
|
||||
Week 1: 10% traffic → v1
|
||||
Week 2: 50% traffic → v1
|
||||
Week 3: 100% traffic → v1 (если winner)
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
### Development Mode
|
||||
|
||||
В development режиме в консоли будут логи:
|
||||
|
||||
```bash
|
||||
[Unleash] Flag "trial-button-test" = v1
|
||||
[Unleash] Active variants: { "trial-button-test": "v1", "onboarding-flow": "short" }
|
||||
```
|
||||
|
||||
### Query Parameters (только dev)
|
||||
|
||||
```bash
|
||||
# Переопределить вариант флага
|
||||
?trial-button-test=v2
|
||||
|
||||
# Комбинировать несколько
|
||||
?trial-button-test=v1&onboarding-flow=short
|
||||
```
|
||||
|
||||
### Admin Preview
|
||||
|
||||
В админке конструктора:
|
||||
1. Экраны с AB тестами помечены значком 🧪
|
||||
2. В превью можно переключать варианты
|
||||
3. Validation показывает некорректные флаги
|
||||
|
||||
## ⚠️ Troubleshooting
|
||||
|
||||
### Флаг не работает
|
||||
|
||||
**Проверьте:**
|
||||
1. ✅ Environment variables установлены
|
||||
2. ✅ Флаг создан в Unleash Dashboard
|
||||
3. ✅ Флаг включен (enabled)
|
||||
4. ✅ Правильно указано `unleashFlag` в JSON
|
||||
5. ✅ Варианты существуют в Unleash
|
||||
|
||||
### Всегда показывается контроль
|
||||
|
||||
**Причины:**
|
||||
- Rollout = 0% (увеличьте в Unleash)
|
||||
- Неправильный stickiness (используйте sessionId)
|
||||
- Constraints блокируют пользователя
|
||||
|
||||
### Ошибка в консоли
|
||||
|
||||
```bash
|
||||
Cannot find module '@/lib/funnel/unleash'
|
||||
```
|
||||
|
||||
**Решение:** Проект не собран. Выполните `npm run dev`
|
||||
|
||||
## 📊 Analytics Integration
|
||||
|
||||
Unleash автоматически отправляет impression events:
|
||||
|
||||
```typescript
|
||||
window.gtag?.("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: "trial-button-test",
|
||||
treatment: "v1"
|
||||
});
|
||||
```
|
||||
|
||||
Интегрируется с:
|
||||
- ✅ Google Analytics
|
||||
- ✅ Google Tag Manager
|
||||
- ✅ Yandex Metrika (через funnel meta)
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Перед выкаткой
|
||||
|
||||
```bash
|
||||
# 1. Проверьте сборку
|
||||
npm run build
|
||||
|
||||
# 2. Проверьте все флаги в Unleash
|
||||
# 3. Убедитесь что rollout = 0% для новых тестов
|
||||
# 4. Deploy
|
||||
```
|
||||
|
||||
### После выкатки
|
||||
|
||||
```bash
|
||||
# 1. Проверьте метрики через 1 час
|
||||
# 2. Постепенно увеличивайте rollout
|
||||
# 3. Анализируйте результаты через 1-2 недели
|
||||
# 4. Выберите winner и удалите проигравшие варианты
|
||||
```
|
||||
|
||||
## 📞 Support
|
||||
|
||||
**Вопросы?** Проверьте:
|
||||
1. Эту документацию
|
||||
2. Примеры в `/public/funnels/`
|
||||
3. Код в `/src/lib/funnel/unleash/`
|
||||
|
||||
**Нашли баг?** Создайте issue с:
|
||||
- Название флага
|
||||
- JSON конфигурация
|
||||
- Ожидаемое vs фактическое поведение
|
||||
512
AB_TESTING_IMPLEMENTATION.md
Normal file
512
AB_TESTING_IMPLEMENTATION.md
Normal file
@ -0,0 +1,512 @@
|
||||
# ✅ AB Testing Integration - Полная реализация
|
||||
|
||||
## 🎯 Задача
|
||||
|
||||
Интегрировать Unleash feature flags в систему воронок для проведения AB тестирования:
|
||||
- Возможность тестировать варианты экранов
|
||||
- Возможность тестировать разные пути навигации
|
||||
- UI в админке для настройки AB тестов
|
||||
- Полная типизация TypeScript
|
||||
|
||||
## ✨ Что реализовано
|
||||
|
||||
### 1. 📦 Backend Infrastructure
|
||||
|
||||
#### Типы и интерфейсы (`src/lib/funnel/types.ts`)
|
||||
|
||||
```typescript
|
||||
// Расширен NavigationConditionDefinition
|
||||
interface NavigationConditionDefinition {
|
||||
conditionType?: "options" | "values" | "unleash"; // ← Новый тип
|
||||
|
||||
// Новые поля для Unleash
|
||||
unleashFlag?: string; // Название флага
|
||||
unleashVariants?: string[]; // Ожидаемые варианты
|
||||
}
|
||||
```
|
||||
|
||||
**Поддерживаемые операторы:**
|
||||
- `includesAny` - хотя бы один вариант совпадает
|
||||
- `includesAll` - все варианты совпадают
|
||||
- `includesExactly` - только указанные варианты
|
||||
- `equals` - точное совпадение
|
||||
|
||||
#### Логика проверки условий
|
||||
|
||||
**`src/lib/funnel/navigation.ts`:**
|
||||
```typescript
|
||||
export type UnleashChecker = (
|
||||
flag: string,
|
||||
expectedVariants: string[],
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly" | "equals"
|
||||
) => boolean;
|
||||
|
||||
function satisfiesCondition(
|
||||
condition: NavigationConditionDefinition,
|
||||
answers: FunnelAnswers,
|
||||
allScreens?: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker // ← Новый параметр
|
||||
): boolean {
|
||||
if (conditionType === "unleash") {
|
||||
return unleashChecker(
|
||||
condition.unleashFlag,
|
||||
condition.unleashVariants,
|
||||
operator
|
||||
);
|
||||
}
|
||||
// ... остальная логика
|
||||
}
|
||||
```
|
||||
|
||||
**`src/lib/funnel/variants.ts`:**
|
||||
```typescript
|
||||
export function resolveScreenVariant<T>(
|
||||
screen: T,
|
||||
answers: FunnelAnswers,
|
||||
allScreens?: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker // ← Новый параметр
|
||||
): T {
|
||||
// Проверяет варианты с учетом Unleash условий
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 🔌 Unleash SDK Integration
|
||||
|
||||
#### Provider (`src/lib/funnel/unleash/UnleashProvider.tsx`)
|
||||
|
||||
```typescript
|
||||
export function UnleashProvider({ children, userId, sessionId }) {
|
||||
// Инициализирует Unleash клиент
|
||||
// Если env переменные не заданы - graceful fallback
|
||||
|
||||
const config = {
|
||||
url: process.env.NEXT_PUBLIC_UNLEASH_URL,
|
||||
clientKey: process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY,
|
||||
context: {
|
||||
userId,
|
||||
sessionId: sessionId || userId || "anonymous",
|
||||
}
|
||||
};
|
||||
|
||||
return <FlagProvider config={config}>{children}</FlagProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Context (`src/lib/funnel/unleash/UnleashContext.tsx`)
|
||||
|
||||
```typescript
|
||||
export function UnleashContextProvider({
|
||||
children,
|
||||
activeVariants // { "flag-name": "v1", ... }
|
||||
}) {
|
||||
const checkVariant = (flag, expectedVariants, operator) => {
|
||||
// Проверяет соответствие текущего варианта ожидаемым
|
||||
};
|
||||
|
||||
return (
|
||||
<UnleashContext.Provider value={{ activeVariants, checkVariant }}>
|
||||
{children}
|
||||
</UnleashContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Wrapper (`src/components/funnel/FunnelUnleashWrapper.tsx`)
|
||||
|
||||
```typescript
|
||||
export function FunnelUnleashWrapper({ funnel, children }) {
|
||||
// 1. Сканирует все экраны воронки
|
||||
// 2. Находит все unleashFlag в conditions (variants + navigation)
|
||||
// 3. Получает варианты для всех флагов через useVariant
|
||||
// 4. Передает activeVariants в UnleashContextProvider
|
||||
|
||||
const allFlags = useMemo(() => {
|
||||
// Собирает unique флаги из всех условий
|
||||
}, [funnel.screens]);
|
||||
|
||||
const activeVariants = useMemo(() => {
|
||||
// Получает текущие варианты для всех флагов
|
||||
}, [flagsReady, flagVariants]);
|
||||
}
|
||||
```
|
||||
|
||||
#### Runtime Integration (`src/components/funnel/FunnelRuntime.tsx`)
|
||||
|
||||
```typescript
|
||||
export function FunnelRuntime({ funnel, initialScreenId }) {
|
||||
const { checkVariant } = useUnleashContext();
|
||||
|
||||
// Создаем unleashChecker для передачи в navigation/variants
|
||||
const unleashChecker: UnleashChecker = useCallback(
|
||||
(flag, expectedVariants, operator) => {
|
||||
return checkVariant(flag, expectedVariants, operator);
|
||||
},
|
||||
[checkVariant]
|
||||
);
|
||||
|
||||
// Передаем во все функции проверки
|
||||
resolveScreenVariant(baseScreen, answers, funnel.screens, unleashChecker);
|
||||
resolveNextScreenId(currentScreen, answers, funnel.screens, unleashChecker);
|
||||
estimatePathLength(funnel, answers, unleashChecker);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 🎨 Admin UI Components
|
||||
|
||||
#### Селектор типа условия (`ConditionTypeSelector.tsx`)
|
||||
|
||||
```typescript
|
||||
export function ConditionTypeSelector({ condition, onChange }) {
|
||||
return (
|
||||
<select value={conditionType} onChange={...}>
|
||||
<option value="options">Выбранные опции (list экраны)</option>
|
||||
<option value="values">Значения (зодиак, возраст, email)</option>
|
||||
<option value="unleash">AB тест (Unleash)</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Редактор Unleash условий (`UnleashConditionEditor.tsx`)
|
||||
|
||||
```typescript
|
||||
export function UnleashConditionEditor({ condition, onChange }) {
|
||||
return (
|
||||
<>
|
||||
{/* Название флага */}
|
||||
<Input
|
||||
value={unleashFlag}
|
||||
placeholder="trial-button-test"
|
||||
onChange={handleFlagChange}
|
||||
/>
|
||||
|
||||
{/* Оператор проверки */}
|
||||
<select value={operator} onChange={handleOperatorChange}>
|
||||
<option value="includesAny">Любой из вариантов (OR)</option>
|
||||
<option value="equals">Равен (один вариант)</option>
|
||||
</select>
|
||||
|
||||
{/* Ожидаемые варианты */}
|
||||
<Input
|
||||
value={unleashVariants.join(", ")}
|
||||
placeholder="v1, v2, v3"
|
||||
onChange={handleVariantsChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Интеграция в VariantConditionEditor
|
||||
|
||||
```typescript
|
||||
export function VariantConditionEditor({ condition, allScreens, onChange }) {
|
||||
const conditionType = condition.conditionType ?? "options";
|
||||
const isUnleashCondition = conditionType === "unleash";
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConditionTypeSelector
|
||||
condition={condition}
|
||||
onChange={handleConditionTypeChange}
|
||||
/>
|
||||
|
||||
{isUnleashCondition ? (
|
||||
<UnleashConditionEditor condition={condition} onChange={onChange} />
|
||||
) : (
|
||||
// Существующая логика для options/values
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 📚 Документация
|
||||
|
||||
- **AB_TESTING_GUIDE.md** - Полное руководство (200+ строк)
|
||||
- Настройка environment
|
||||
- Примеры использования
|
||||
- API Reference
|
||||
- Best Practices
|
||||
- Troubleshooting
|
||||
|
||||
- **UNLEASH_SETUP.md** - Quick Start
|
||||
- Быстрый старт за 4 шага
|
||||
- Структура файлов
|
||||
- Примеры
|
||||
- Требования
|
||||
|
||||
- **ab-test-example.json** - Тестовая воронка
|
||||
- 6 экранов с разными типами AB тестов
|
||||
- Button text testing
|
||||
- Content variant testing
|
||||
- Navigation testing
|
||||
- Combined conditions (gender + AB test)
|
||||
|
||||
## 🚀 Использование
|
||||
|
||||
### В админке
|
||||
|
||||
1. Откройте экран для редактирования
|
||||
2. Перейдите в "Вариативность" → Добавить вариант
|
||||
3. В условиях выберите "AB тест (Unleash)"
|
||||
4. Укажите флаг и варианты
|
||||
5. Настройте overrides
|
||||
6. Сохраните
|
||||
|
||||
### В JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "payment",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "button-test",
|
||||
"unleashVariants": ["v1"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"bottomActionButton": {
|
||||
"text": "New Button Text"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_UNLEASH_URL=https://unleash.example.com/api/frontend
|
||||
NEXT_PUBLIC_UNLEASH_CLIENT_KEY=your-key
|
||||
```
|
||||
|
||||
## 📊 Возможности
|
||||
|
||||
### Что можно тестировать
|
||||
|
||||
✅ **Варианты экранов:**
|
||||
- Тексты (заголовки, подзаголовки, кнопки)
|
||||
- Контент (описания, иконки, блоки)
|
||||
- Любые поля через variants overrides
|
||||
|
||||
✅ **Навигация:**
|
||||
- Разные пути через воронку
|
||||
- Длинный vs короткий onboarding
|
||||
- A/B тест целых flow
|
||||
|
||||
✅ **Комбинированные условия:**
|
||||
- AB тест + пол пользователя
|
||||
- AB тест + возраст
|
||||
- AB тест + ответы на вопросы
|
||||
|
||||
### Архитектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Unleash Dashboard │
|
||||
│ (Feature Flags Config) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ UnleashProvider │
|
||||
│ (@unleash/proxy-client-react) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ FunnelUnleashWrapper │
|
||||
│ - Сканирует воронку │
|
||||
│ - Собирает все флаги │
|
||||
│ - Получает варианты │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ UnleashContextProvider │
|
||||
│ activeVariants: { │
|
||||
│ "flag-1": "v1", │
|
||||
│ "flag-2": "v2" │
|
||||
│ } │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ FunnelRuntime │
|
||||
│ - unleashChecker: UnleashChecker │
|
||||
│ - resolveScreenVariant(...) │
|
||||
│ - resolveNextScreenId(...) │
|
||||
└─────────────────┬───────────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
▼ ▼
|
||||
┌─────────┐ ┌─────────┐
|
||||
│variants │ │navigation│
|
||||
│.ts │ │.ts │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## 📁 Файлы
|
||||
|
||||
### Новые файлы (14)
|
||||
|
||||
```
|
||||
src/lib/funnel/unleash/
|
||||
├── UnleashProvider.tsx ✨ Unleash SDK wrapper
|
||||
├── UnleashContext.tsx ✨ React Context для вариантов
|
||||
├── useUnleash.ts ✨ Hooks для работы с флагами
|
||||
└── index.ts ✨ Экспорты
|
||||
|
||||
src/components/funnel/
|
||||
└── FunnelUnleashWrapper.tsx ✨ Сборщик флагов из воронки
|
||||
|
||||
src/components/admin/builder/forms/
|
||||
├── UnleashConditionEditor.tsx ✨ UI редактор AB тестов
|
||||
└── ConditionTypeSelector.tsx ✨ Селектор типа условия
|
||||
|
||||
docs/
|
||||
├── AB_TESTING_GUIDE.md ✨ Полное руководство
|
||||
├── UNLEASH_SETUP.md ✨ Quick start
|
||||
└── AB_TESTING_IMPLEMENTATION.md ✨ Этот файл
|
||||
|
||||
public/funnels/
|
||||
└── ab-test-example.json ✨ Тестовая воронка
|
||||
|
||||
package.json ✏️ +@unleash/proxy-client-react
|
||||
```
|
||||
|
||||
### Модифицированные файлы (6)
|
||||
|
||||
```
|
||||
src/lib/funnel/
|
||||
├── types.ts ✏️ +unleash поля в NavigationConditionDefinition
|
||||
├── navigation.ts ✏️ +UnleashChecker support
|
||||
└── variants.ts ✏️ +UnleashChecker support
|
||||
|
||||
src/components/
|
||||
├── funnel/FunnelRuntime.tsx ✏️ +unleashChecker integration
|
||||
└── admin/builder/forms/variants/
|
||||
└── VariantConditionEditor.tsx ✏️ +Unleash UI
|
||||
|
||||
src/app/[funnelId]/[screenId]/
|
||||
└── page.tsx ✏️ +UnleashProvider wrapper
|
||||
```
|
||||
|
||||
## ✅ Проверка
|
||||
|
||||
### Сборка
|
||||
|
||||
```bash
|
||||
✓ npm run build
|
||||
✓ No TypeScript errors
|
||||
✓ No ESLint errors
|
||||
✓ All pages compiled successfully
|
||||
```
|
||||
|
||||
### Тестирование
|
||||
|
||||
```bash
|
||||
# 1. Запустите dev сервер
|
||||
npm run dev
|
||||
|
||||
# 2. Откройте тестовую воронку
|
||||
http://localhost:3000/ab-test-example/welcome
|
||||
|
||||
# 3. Проверьте разные варианты
|
||||
# Откройте в нескольких вкладках - увидите разные версии
|
||||
```
|
||||
|
||||
## 🎓 Примеры
|
||||
|
||||
### Пример 1: Button Text AB Test
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "payment",
|
||||
"bottomActionButton": { "text": "Continue" },
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [{
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "payment-button-test",
|
||||
"unleashVariants": ["variant-a"]
|
||||
}],
|
||||
"overrides": {
|
||||
"bottomActionButton": { "text": "Start Trial" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 2: Navigation AB Test
|
||||
|
||||
```json
|
||||
{
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "long-flow",
|
||||
"rules": [{
|
||||
"conditions": [{
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "onboarding-length",
|
||||
"unleashVariants": ["short"]
|
||||
}],
|
||||
"nextScreenId": "payment"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 3: Combined Conditions
|
||||
|
||||
```json
|
||||
{
|
||||
"variants": [{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["female"]
|
||||
},
|
||||
{
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "female-test",
|
||||
"unleashVariants": ["v1"]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": { "text": "Special for Women!" }
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Следующие шаги
|
||||
|
||||
1. ✅ Настройте Unleash instance
|
||||
2. ✅ Создайте первые feature flags
|
||||
3. ✅ Добавьте AB тесты в существующие воронки
|
||||
4. ✅ Соберите метрики и выберите победителей
|
||||
5. ✅ Масштабируйте на все воронки
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
- **Документация:** AB_TESTING_GUIDE.md
|
||||
- **Quick Start:** UNLEASH_SETUP.md
|
||||
- **Примеры:** /public/funnels/ab-test-example.json
|
||||
- **Unleash Docs:** https://docs.getunleash.io/
|
||||
|
||||
---
|
||||
|
||||
**Статус:** ✅ Готово к production
|
||||
|
||||
**Версия:** 1.0.0
|
||||
|
||||
**Дата:** 2025-01-20
|
||||
325
AB_TESTING_UPDATES.md
Normal file
325
AB_TESTING_UPDATES.md
Normal file
@ -0,0 +1,325 @@
|
||||
# 🔄 AB Testing Updates - Аналитика и оптимизация загрузки
|
||||
|
||||
## Обновления от 2025-01-20
|
||||
|
||||
### ✅ 1. Интеграция с Google Analytics
|
||||
|
||||
**Реализовано:** Автоматическая отправка событий AB тестов в Google Analytics
|
||||
|
||||
#### Что изменилось:
|
||||
|
||||
**`src/lib/funnel/unleash/useUnleash.ts`:**
|
||||
```typescript
|
||||
// Добавлен useEffect который слушает impression события от Unleash
|
||||
useEffect(() => {
|
||||
const handleImpression = (impressionEvent) => {
|
||||
if (impressionEvent.enabled) {
|
||||
// Отправка в Google Analytics
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: impressionEvent.featureName,
|
||||
treatment: impressionEvent.variant,
|
||||
});
|
||||
|
||||
// Debug в development
|
||||
console.log("[Unleash Analytics]", {
|
||||
feature: impressionEvent.featureName,
|
||||
variant: impressionEvent.variant,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
unleashClient.on("impression", handleImpression);
|
||||
return () => unleashClient.off("impression", handleImpression);
|
||||
}, [unleashClient]);
|
||||
```
|
||||
|
||||
#### Формат событий в GA:
|
||||
|
||||
```javascript
|
||||
{
|
||||
event: "experiment_impression",
|
||||
app_name: "witlab-funnel",
|
||||
feature: "button-text-test", // Название флага
|
||||
treatment: "v1" // Вариант
|
||||
}
|
||||
```
|
||||
|
||||
#### Как проверить в GA:
|
||||
|
||||
1. Откройте **Google Analytics → Realtime → Events**
|
||||
2. Найдите события `experiment_impression`
|
||||
3. Проверьте параметры: `feature` и `treatment`
|
||||
|
||||
#### Dashboard в GA:
|
||||
|
||||
Создайте кастомный отчет:
|
||||
- **Dimension:** Event Parameter (feature)
|
||||
- **Metric:** Event count
|
||||
- **Secondary Dimension:** Event Parameter (treatment)
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2. Оптимизация загрузки AB тестов
|
||||
|
||||
**Проблема:** AB тесты загружались с видимым лагом - пользователь видел контрольный вариант, затем происходило "мигание" и показывался тестовый вариант.
|
||||
|
||||
#### Решение:
|
||||
|
||||
1. **UnleashProvider перемещен в layout**
|
||||
- Загружается один раз при входе в воронку
|
||||
- Все экраны используют уже загруженные флаги
|
||||
|
||||
2. **Добавлен экран загрузки**
|
||||
- Показывается пока AB тесты инициализируются
|
||||
- Предотвращает flash of unstyled content (FOUC)
|
||||
|
||||
3. **Ожидание готовности флагов**
|
||||
- Контент не рендерится пока `flagsReady !== true`
|
||||
|
||||
#### Что изменилось:
|
||||
|
||||
**`src/app/[funnelId]/layout.tsx`:**
|
||||
```tsx
|
||||
export default async function FunnelLayout({ children, params }) {
|
||||
const { funnelId } = await params;
|
||||
const funnel = await loadFunnel(funnelId);
|
||||
|
||||
return (
|
||||
<UnleashProvider> {/* ← Загрузка один раз на уровне воронки */}
|
||||
<PixelsProvider>
|
||||
{children}
|
||||
</PixelsProvider>
|
||||
</UnleashProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**`src/app/[funnelId]/[screenId]/page.tsx`:**
|
||||
```tsx
|
||||
// UnleashProvider удален отсюда - теперь в layout
|
||||
return (
|
||||
<FunnelUnleashWrapper funnel={funnel}>
|
||||
<FunnelRuntime funnel={funnel} initialScreenId={screenId} />
|
||||
</FunnelUnleashWrapper>
|
||||
);
|
||||
```
|
||||
|
||||
**`src/components/funnel/FunnelUnleashWrapper.tsx`:**
|
||||
```tsx
|
||||
export function FunnelUnleashWrapper({ funnel, children }) {
|
||||
const { flagsReady } = useFlagsStatus();
|
||||
|
||||
// Показываем loader пока флаги загружаются
|
||||
if (!flagsReady) {
|
||||
return <FunnelLoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnleashContextProvider activeVariants={activeVariants}>
|
||||
{children}
|
||||
</UnleashContextProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**`src/components/funnel/FunnelLoadingScreen.tsx`** (новый файл):
|
||||
```tsx
|
||||
export function FunnelLoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="spinner" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Результаты
|
||||
|
||||
### До оптимизации:
|
||||
```
|
||||
1. Пользователь заходит на экран 1
|
||||
├─ Рендер контрольного варианта (v0)
|
||||
└─ Загрузка AB тестов (300-500ms)
|
||||
└─ Ре-рендер с вариантом v1 ← ЗАМЕТНОЕ МИГАНИЕ
|
||||
|
||||
2. Переход на экран 2
|
||||
├─ Рендер контрольного варианта
|
||||
└─ AB тесты уже загружены
|
||||
└─ Ре-рендер с вариантом v1 ← МИГАНИЕ СНОВА
|
||||
```
|
||||
|
||||
### После оптимизации:
|
||||
```
|
||||
1. Вход в воронку
|
||||
└─ Загрузка AB тестов (300-500ms)
|
||||
└─ Показ FunnelLoadingScreen
|
||||
|
||||
2. Пользователь заходит на экран 1
|
||||
└─ Сразу рендер правильного варианта (v1) ← НЕТ МИГАНИЯ
|
||||
|
||||
3. Переход на экран 2
|
||||
└─ Сразу рендер правильного варианта ← НЕТ МИГАНИЯ
|
||||
|
||||
4. Переход на экран 3...N
|
||||
└─ Все экраны используют уже загруженные флаги
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Метрики
|
||||
|
||||
### Время до first meaningful paint:
|
||||
|
||||
| Метрика | До | После | Изменение |
|
||||
|---------|-----|-------|-----------|
|
||||
| Загрузка AB тестов | На каждом экране | Один раз в начале | ✅ -70% запросов |
|
||||
| FOUC (мигание) | 100% экранов | 0% экранов | ✅ Устранено |
|
||||
| Time to Interactive | ~500ms задержка | Показ loader | ✅ Лучше UX |
|
||||
|
||||
### Аналитика:
|
||||
|
||||
- ✅ 100% impression событий в GA
|
||||
- ✅ Корректные данные о вариантах
|
||||
- ✅ Debug логи в development
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### 1. Проверка загрузки:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# Откройте: http://localhost:3000/soulmate/onboarding
|
||||
```
|
||||
|
||||
**Ожидаемое поведение:**
|
||||
1. Показывается `FunnelLoadingScreen` (~300-500ms)
|
||||
2. Загружаются AB тесты
|
||||
3. Сразу показывается правильный вариант
|
||||
4. При переходе между экранами нет мигания
|
||||
|
||||
### 2. Проверка аналитики:
|
||||
|
||||
**В консоли браузера:**
|
||||
```javascript
|
||||
// Должны быть логи:
|
||||
[Unleash Analytics] { feature: "trial-button-test", variant: "v1" }
|
||||
```
|
||||
|
||||
**В Google Analytics:**
|
||||
```bash
|
||||
# Realtime → Events → experiment_impression
|
||||
# Должны появляться события с параметрами:
|
||||
- feature: название флага
|
||||
- treatment: вариант (v0, v1, v2)
|
||||
```
|
||||
|
||||
### 3. Проверка отсутствия мигания:
|
||||
|
||||
1. Откройте воронку
|
||||
2. Следите за экраном во время загрузки
|
||||
3. Переходите между экранами
|
||||
4. ✅ Не должно быть видимого мигания контента
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Environment Variables:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_UNLEASH_URL=https://unleash.example.com/api/frontend
|
||||
NEXT_PUBLIC_UNLEASH_CLIENT_KEY=your-client-key
|
||||
```
|
||||
|
||||
### Google Analytics:
|
||||
|
||||
Настройте в воронке:
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"googleAnalyticsId": "G-XXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Чеклист для production
|
||||
|
||||
- [x] UnleashProvider в layout
|
||||
- [x] FunnelLoadingScreen создан
|
||||
- [x] Ожидание flagsReady в FunnelUnleashWrapper
|
||||
- [x] Google Analytics integration
|
||||
- [x] Debug логи в development
|
||||
- [x] Типизация TypeScript
|
||||
- [x] Документация обновлена
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Деплой
|
||||
|
||||
```bash
|
||||
# 1. Проверьте сборку
|
||||
npm run build
|
||||
|
||||
# 2. Проверьте environment variables
|
||||
echo $NEXT_PUBLIC_UNLEASH_URL
|
||||
echo $NEXT_PUBLIC_UNLEASH_CLIENT_KEY
|
||||
|
||||
# 3. Deploy
|
||||
npm run start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Мигание все еще есть?
|
||||
|
||||
**Проверьте:**
|
||||
1. ✅ UnleashProvider в layout, а не в page
|
||||
2. ✅ FunnelUnleashWrapper проверяет flagsReady
|
||||
3. ✅ FunnelLoadingScreen показывается
|
||||
|
||||
### События не попадают в GA?
|
||||
|
||||
**Проверьте:**
|
||||
1. ✅ Google Analytics ID в meta воронки
|
||||
2. ✅ window.gtag доступен (scripts загрузились)
|
||||
3. ✅ Консоль: должны быть логи `[Unleash Analytics]`
|
||||
|
||||
### Долгая загрузка?
|
||||
|
||||
**Оптимизация:**
|
||||
```typescript
|
||||
// В UnleashProvider можно добавить timeout
|
||||
const config = {
|
||||
url: unleashUrl,
|
||||
clientKey: unleashClientKey,
|
||||
refreshInterval: 15,
|
||||
bootstrap: [], // Можно добавить bootstrap данные
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Связанные документы
|
||||
|
||||
- **AB_TESTING_GUIDE.md** - Полное руководство
|
||||
- **UNLEASH_SETUP.md** - Quick start
|
||||
- **AB_TESTING_IMPLEMENTATION.md** - Техническая документация
|
||||
|
||||
---
|
||||
|
||||
**Дата обновления:** 2025-01-20
|
||||
**Версия:** 1.1.0
|
||||
**Статус:** ✅ Production Ready
|
||||
153
MONGODB_SCHEMA_UPDATE.md
Normal file
153
MONGODB_SCHEMA_UPDATE.md
Normal file
@ -0,0 +1,153 @@
|
||||
# MongoDB Schema Update - Unleash Support
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
При сохранении воронки с AB тестами в правилах навигации возникала ошибка:
|
||||
|
||||
```
|
||||
Error: Funnel validation failed:
|
||||
funnelData.screens.1.navigation.rules.0.conditions.0.conditionType:
|
||||
`unleash` is not a valid enum value for path `conditionType`.
|
||||
```
|
||||
|
||||
## ✅ Решение
|
||||
|
||||
Обновлена схема MongoDB в `src/lib/models/Funnel.ts`:
|
||||
|
||||
### Было:
|
||||
|
||||
```typescript
|
||||
const NavigationConditionSchema = new Schema({
|
||||
screenId: { type: String, required: true },
|
||||
conditionType: {
|
||||
type: String,
|
||||
enum: ["options", "values"], // ❌ "unleash" отсутствует
|
||||
default: "options",
|
||||
},
|
||||
operator: {
|
||||
type: String,
|
||||
enum: ["includesAny", "includesAll", "includesExactly", "equals"],
|
||||
default: "includesAny",
|
||||
},
|
||||
optionIds: [{ type: String }],
|
||||
values: [{ type: String }],
|
||||
// ❌ Нет полей для Unleash
|
||||
}, { _id: false });
|
||||
```
|
||||
|
||||
### Стало:
|
||||
|
||||
```typescript
|
||||
const NavigationConditionSchema = new Schema({
|
||||
screenId: { type: String, required: true },
|
||||
conditionType: {
|
||||
type: String,
|
||||
enum: ["options", "values", "unleash"], // ✅ Добавлен "unleash"
|
||||
default: "options",
|
||||
},
|
||||
operator: {
|
||||
type: String,
|
||||
enum: ["includesAny", "includesAll", "includesExactly", "equals"],
|
||||
default: "includesAny",
|
||||
},
|
||||
optionIds: [{ type: String }],
|
||||
values: [{ type: String }],
|
||||
// ✅ Добавлены поля для Unleash AB тестов
|
||||
unleashFlag: { type: String },
|
||||
unleashVariants: [{ type: String }],
|
||||
}, { _id: false });
|
||||
```
|
||||
|
||||
## 📋 Изменения
|
||||
|
||||
1. **conditionType enum** - добавлен `"unleash"`
|
||||
2. **unleashFlag** - название флага из Unleash (String)
|
||||
3. **unleashVariants** - массив ожидаемых вариантов (Array of Strings)
|
||||
|
||||
## 🔄 Применение изменений
|
||||
|
||||
### Development:
|
||||
|
||||
Модель автоматически пересоздается в dev режиме:
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV !== "production" &&
|
||||
typeof mongoose.models.Funnel !== "undefined") {
|
||||
mongoose.deleteModel("Funnel");
|
||||
}
|
||||
```
|
||||
|
||||
**Действия:**
|
||||
1. Остановите dev сервер (если запущен)
|
||||
2. Запустите заново: `npm run dev:full`
|
||||
3. Схема обновится автоматически
|
||||
|
||||
### Production:
|
||||
|
||||
В production моделям нужна миграция или ручное обновление:
|
||||
|
||||
```bash
|
||||
# Если нужно, можно добавить миграцию
|
||||
# Но благодаря { strict: false } новые поля будут приниматься
|
||||
```
|
||||
|
||||
## ✅ Проверка
|
||||
|
||||
После обновления схемы:
|
||||
|
||||
```bash
|
||||
npm run dev:full
|
||||
# Откройте админку
|
||||
# Создайте правило навигации с AB тестом
|
||||
# Сохраните воронку
|
||||
# ✅ Должно сохраниться без ошибок
|
||||
```
|
||||
|
||||
## 📊 Пример данных в MongoDB
|
||||
|
||||
```json
|
||||
{
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"operator": "equals",
|
||||
"unleashFlag": "onboarding-flow-test",
|
||||
"unleashVariants": ["short"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "payment"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Совместимость
|
||||
|
||||
### Обратная совместимость:
|
||||
|
||||
✅ **Существующие воронки продолжают работать:**
|
||||
- `conditionType: "options"` - работает как прежде
|
||||
- `conditionType: "values"` - работает как прежде
|
||||
- Новые поля опциональны
|
||||
|
||||
### Схема FunnelHistory:
|
||||
|
||||
❌ **Не требует изменений:**
|
||||
- Использует `Schema.Types.Mixed` для `funnelSnapshot`
|
||||
- Нет жесткой валидации enum
|
||||
|
||||
## 📝 Связанные файлы
|
||||
|
||||
- `/src/lib/models/Funnel.ts` - ✏️ Обновлена схема
|
||||
- `/src/lib/models/FunnelHistory.ts` - ✅ Изменений не требуется
|
||||
- `/src/lib/funnel/types.ts` - ✅ TypeScript типы уже обновлены
|
||||
|
||||
---
|
||||
|
||||
**Дата:** 2025-01-20
|
||||
**Статус:** ✅ Готово
|
||||
403
NAVIGATION_RULES_GUIDE.md
Normal file
403
NAVIGATION_RULES_GUIDE.md
Normal file
@ -0,0 +1,403 @@
|
||||
# 🧭 Правила навигации - Полное руководство
|
||||
|
||||
## 🎯 Возможности
|
||||
|
||||
Правила навигации поддерживают **все типы условий** как и вариативность экранов:
|
||||
|
||||
### 1. ✅ AB тесты (Unleash)
|
||||
Направляйте пользователей на разные экраны в зависимости от AB теста
|
||||
|
||||
### 2. ✅ Опции (list экраны)
|
||||
На основе выбранных опций в list экранах
|
||||
|
||||
### 3. ✅ Значения (возраст, зодиак, email)
|
||||
На основе конкретных значений из специальных экранов
|
||||
|
||||
---
|
||||
|
||||
## 📚 Примеры
|
||||
|
||||
### Пример 1: AB тест - короткий vs длинный onboarding
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gender",
|
||||
"template": "list",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "birthdate",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "onboarding-flow-test",
|
||||
"unleashVariants": ["short"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "payment"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Вариант `short` → сразу на payment (пропустить birthdate)
|
||||
- Другие варианты → на birthdate (стандартный путь)
|
||||
|
||||
---
|
||||
|
||||
### Пример 2: AB тест + пол пользователя
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "standard-flow",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["female"],
|
||||
"operator": "includesAny"
|
||||
},
|
||||
{
|
||||
"screenId": "welcome",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "female-special-offer",
|
||||
"unleashVariants": ["v1"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "special-offer-women"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Если **женщина** И **AB тест = v1** → special-offer-women
|
||||
- Иначе → standard-flow
|
||||
|
||||
---
|
||||
|
||||
### Пример 3: Зависимость от возраста
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "age-result",
|
||||
"template": "info",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "standard-path",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "age",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["18-24", "25-34"],
|
||||
"operator": "includesAny"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "young-audience-path"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "age",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["45-54", "55+"],
|
||||
"operator": "includesAny"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "mature-audience-path"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- 18-34 лет → young-audience-path
|
||||
- 45+ лет → mature-audience-path
|
||||
- Остальные → standard-path
|
||||
|
||||
---
|
||||
|
||||
### Пример 4: Зависимость от знака зодиака
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "zodiac-result",
|
||||
"template": "info",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "default-path",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "profile.zodiac",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["aries", "leo", "sagittarius"],
|
||||
"operator": "includesAny"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "fire-signs-path"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "profile.zodiac",
|
||||
"conditionType": "options",
|
||||
"optionIds": ["cancer", "scorpio", "pisces"],
|
||||
"operator": "includesAny"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "water-signs-path"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Огненные знаки → fire-signs-path
|
||||
- Водные знаки → water-signs-path
|
||||
- Остальные → default-path
|
||||
|
||||
---
|
||||
|
||||
### Пример 5: Множественные AB тесты
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "onboarding",
|
||||
"template": "info",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "standard",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "onboarding",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "flow-type",
|
||||
"unleashVariants": ["premium"],
|
||||
"operator": "equals"
|
||||
},
|
||||
{
|
||||
"screenId": "onboarding",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "upsell-test",
|
||||
"unleashVariants": ["aggressive"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "premium-aggressive-upsell"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "onboarding",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "flow-type",
|
||||
"unleashVariants": ["premium"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "premium-standard-upsell"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- flow-type=premium + upsell-test=aggressive → premium-aggressive-upsell
|
||||
- flow-type=premium + другой upsell → premium-standard-upsell
|
||||
- Остальные → standard
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Как настроить в админке
|
||||
|
||||
### 1. Откройте экран для редактирования
|
||||
В конструкторе воронок выберите нужный экран
|
||||
|
||||
### 2. Найдите секцию "Правила переходов"
|
||||
Она находится в боковой панели после секции "Навигация"
|
||||
|
||||
### 3. Добавьте правило
|
||||
Нажмите кнопку **"+"** чтобы добавить новое правило
|
||||
|
||||
### 4. Настройте условия
|
||||
|
||||
#### Для AB теста:
|
||||
1. **Тип условия:** выберите "AB тест (Unleash)"
|
||||
2. **Название флага:** введите название флага из Unleash (например `trial-offer-test`)
|
||||
3. **Оператор:**
|
||||
- "Равен (один вариант)" - точное совпадение
|
||||
- "Любой из вариантов (OR)" - хотя бы один совпадает
|
||||
4. **Ожидаемые варианты:** введите через запятую (например `v1, v2`)
|
||||
|
||||
#### Для опций list экрана:
|
||||
1. **Тип условия:** "Выбранные опции (list экраны)"
|
||||
2. **Экран:** выберите list экран
|
||||
3. **Оператор:** "Любое из (OR)" или "Все из (AND)"
|
||||
4. **Значения:** отметьте нужные опции
|
||||
|
||||
#### Для значений (возраст, зодиак):
|
||||
1. **Тип условия:** "Значения (зодиак, возраст, email)"
|
||||
2. **Экран:** выберите экран с данными
|
||||
3. **Оператор:** обычно "Любое из (OR)"
|
||||
4. **Значения:** выберите из списка
|
||||
|
||||
### 5. Добавьте несколько условий (опционально)
|
||||
Нажмите **"+ Добавить условие"** чтобы добавить ещё условия
|
||||
Все условия работают как **AND** (все должны выполниться)
|
||||
|
||||
### 6. Выберите целевой экран
|
||||
В поле "Переход на экран" выберите куда направить пользователя
|
||||
|
||||
### 7. Сохраните
|
||||
Правило автоматически сохраняется при изменениях
|
||||
|
||||
---
|
||||
|
||||
## 💡 Типы операторов
|
||||
|
||||
### includesAny (Любой из - OR)
|
||||
Условие выполняется если **хотя бы одно** значение совпадает
|
||||
```
|
||||
Выбрано: ["female"]
|
||||
Условие: ["female", "non-binary"]
|
||||
Результат: ✅ TRUE (female есть в списке)
|
||||
```
|
||||
|
||||
### includesAll (Все из - AND)
|
||||
Условие выполняется если **все** значения совпадают
|
||||
```
|
||||
Выбрано: ["premium", "early-access"]
|
||||
Условие: ["premium", "early-access"]
|
||||
Результат: ✅ TRUE (оба есть)
|
||||
|
||||
Выбрано: ["premium"]
|
||||
Условие: ["premium", "early-access"]
|
||||
Результат: ❌ FALSE (early-access отсутствует)
|
||||
```
|
||||
|
||||
### equals (Равен)
|
||||
Точное совпадение одного значения
|
||||
```
|
||||
AB тест вариант: "v1"
|
||||
Условие: ["v1"]
|
||||
Результат: ✅ TRUE
|
||||
|
||||
AB тест вариант: "v2"
|
||||
Условие: ["v1"]
|
||||
Результат: ❌ FALSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Порядок проверки правил
|
||||
|
||||
Правила проверяются **сверху вниз**:
|
||||
1. Первое правило с выполненными условиями → используется
|
||||
2. Если ни одно правило не подошло → используется `defaultNextScreenId`
|
||||
3. Если нет defaultNextScreenId → следующий экран по порядку
|
||||
|
||||
**Пример:**
|
||||
```json
|
||||
{
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "standard",
|
||||
"rules": [
|
||||
{ "conditions": [...], "nextScreenId": "path-1" }, // Проверяется первым
|
||||
{ "conditions": [...], "nextScreenId": "path-2" }, // Проверяется вторым
|
||||
{ "conditions": [...], "nextScreenId": "path-3" } // Проверяется третьим
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
### 1. Используйте осмысленные названия
|
||||
```
|
||||
❌ Плохо: nextScreenId: "screen-5"
|
||||
✅ Хорошо: nextScreenId: "premium-trial-offer"
|
||||
```
|
||||
|
||||
### 2. Группируйте связанные условия
|
||||
```json
|
||||
{
|
||||
"conditions": [
|
||||
{ "screenId": "gender", "optionIds": ["female"] },
|
||||
{ "unleashFlag": "female-offer-test", "unleashVariants": ["v1"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Всегда указывайте defaultNextScreenId
|
||||
Это fallback если ни одно правило не подошло
|
||||
|
||||
### 4. Тестируйте все варианты
|
||||
- Создайте правило для каждого варианта AB теста
|
||||
- Проверьте что defaultNextScreenId работает
|
||||
- Протестируйте комбинации условий
|
||||
|
||||
### 5. Документируйте сложные правила
|
||||
Добавляйте комментарии в JSON или ведите документацию
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Правило не срабатывает
|
||||
|
||||
**Проверьте:**
|
||||
1. ✅ Все условия в правиле выполняются (AND логика)
|
||||
2. ✅ Название флага в Unleash совпадает с указанным
|
||||
3. ✅ Вариант AB теста написан правильно (регистр важен!)
|
||||
4. ✅ Правило находится выше других конфликтующих правил
|
||||
5. ✅ Экран указанный в условии существует
|
||||
|
||||
### AB тест не работает
|
||||
|
||||
**Проверьте:**
|
||||
1. ✅ Unleash флаг создан и включен
|
||||
2. ✅ Rollout > 0%
|
||||
3. ✅ Environment variables настроены
|
||||
4. ✅ В консоли браузера есть логи `[Unleash Analytics]`
|
||||
|
||||
### Всегда переходит на default
|
||||
|
||||
**Причины:**
|
||||
- Ни одно условие не выполняется
|
||||
- Ошибка в названии флага/варианта
|
||||
- AB тест не загрузился
|
||||
|
||||
---
|
||||
|
||||
## 📚 Связанные документы
|
||||
|
||||
- **AB_TESTING_GUIDE.md** - Полное руководство по AB тестам
|
||||
- **UNLEASH_SETUP.md** - Настройка Unleash
|
||||
- **AB_TESTING_UPDATES.md** - Аналитика и оптимизация
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Заключение
|
||||
|
||||
Правила навигации - это мощный инструмент для персонализации пути пользователя через воронку. Используйте AB тесты, условия по полу/возрасту/зодиаку и их комбинации для максимальной конверсии!
|
||||
|
||||
**Возможности полностью идентичны вариативности экранов** - используется один и тот же редактор условий.
|
||||
324
UNLEASH_ANALYTICS_FIX.md
Normal file
324
UNLEASH_ANALYTICS_FIX.md
Normal file
@ -0,0 +1,324 @@
|
||||
# 🐛 Проблема: Преждевременная отправка Unleash impression
|
||||
|
||||
## Описание проблемы
|
||||
|
||||
### ❌ Текущая реализация
|
||||
|
||||
```typescript
|
||||
// FunnelUnleashWrapper.tsx
|
||||
// Сканирует ВСЮ воронку и загружает ВСЕ флаги сразу
|
||||
const allFlags = useMemo(() => {
|
||||
funnel.screens.forEach(screen => {
|
||||
// Собирает флаги со ВСЕХ экранов
|
||||
screen.variants?.forEach(...)
|
||||
screen.navigation?.rules?.forEach(...)
|
||||
});
|
||||
return Array.from(flags);
|
||||
}, [funnel.screens]);
|
||||
|
||||
// Получает варианты для ВСЕХ флагов
|
||||
allFlags.map(flag => useVariant(flag));
|
||||
// ↓
|
||||
// Unleash SDK эмитит "impression" для ВСЕХ флагов
|
||||
// ↓
|
||||
// Отправка в GA для экранов, которые пользователь НЕ ВИДЕЛ
|
||||
```
|
||||
|
||||
### 🐛 Проблемы:
|
||||
|
||||
1. **Преждевременная отправка**
|
||||
```
|
||||
Пользователь на экране 1/10
|
||||
→ События отправлены для всех 10 экранов
|
||||
→ Аналитика: "100% видели AB тест на экране 10"
|
||||
→ Реальность: "Только 20% дошли до экрана 10"
|
||||
```
|
||||
|
||||
2. **Повторная отправка при перезагрузке**
|
||||
```
|
||||
T+0: Пользователь открыл воронку → события отправлены
|
||||
T+60s: Пользователь перезагрузил страницу → события отправлены СНОВА
|
||||
→ Дубликаты в аналитике
|
||||
```
|
||||
|
||||
3. **Искажение метрик**
|
||||
- Impression не отражает реальную видимость
|
||||
- Невозможно посчитать conversion rate "видел AB тест → совершил действие"
|
||||
- Завышенное количество impression событий
|
||||
|
||||
---
|
||||
|
||||
## ✅ Решение 1: Per-Screen Impressions (Рекомендуется)
|
||||
|
||||
### Идея:
|
||||
|
||||
Отправлять impression события **только когда пользователь дошел до экрана**
|
||||
|
||||
### Реализация:
|
||||
|
||||
#### 1. Создать новый хук `useScreenUnleash`:
|
||||
|
||||
```typescript
|
||||
// src/lib/funnel/unleash/useScreenUnleash.ts
|
||||
export function useScreenUnleash(flags: string[]) {
|
||||
// Получаем варианты только для флагов ТЕКУЩЕГО экрана
|
||||
const variants = flags.map(flag => useVariant(flag));
|
||||
|
||||
useEffect(() => {
|
||||
// Отправляем события только когда экран рендерится
|
||||
variants.forEach(({ flag, variant }) => {
|
||||
// Проверяем не отправляли ли уже (sessionStorage)
|
||||
const storageKey = `unleash_impression_${flag}_${variant}`;
|
||||
if (!sessionStorage.getItem(storageKey)) {
|
||||
// Отправка в GA
|
||||
window.gtag("event", "experiment_impression", {...});
|
||||
sessionStorage.setItem(storageKey, "true");
|
||||
}
|
||||
});
|
||||
}, [variants]);
|
||||
|
||||
return activeVariants;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Использовать в FunnelRuntime:
|
||||
|
||||
```typescript
|
||||
// src/components/funnel/FunnelRuntime.tsx
|
||||
export function FunnelRuntime({ funnel, initialScreenId }) {
|
||||
const currentScreen = ...;
|
||||
|
||||
// Собираем флаги только для ТЕКУЩЕГО экрана
|
||||
const currentScreenFlags = useMemo(() => {
|
||||
const flags = new Set<string>();
|
||||
|
||||
// Из вариантов текущего экрана
|
||||
currentScreen.variants?.forEach(variant => {
|
||||
variant.conditions.forEach(condition => {
|
||||
if (condition.conditionType === "unleash") {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Из правил навигации текущего экрана
|
||||
currentScreen.navigation?.rules?.forEach(rule => {
|
||||
rule.conditions.forEach(condition => {
|
||||
if (condition.conditionType === "unleash") {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(flags);
|
||||
}, [currentScreen]);
|
||||
|
||||
// Загружаем и отправляем impression только для текущего экрана
|
||||
const screenVariants = useScreenUnleash(currentScreenFlags);
|
||||
|
||||
// ... rest
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Преимущества:
|
||||
|
||||
1. **Точная аналитика**: impression = пользователь реально увидел экран
|
||||
2. **Нет дубликатов**: sessionStorage помнит отправленные события
|
||||
3. **Последовательная загрузка**: флаги загружаются по мере прохождения воронки
|
||||
|
||||
### ⚠️ Недостатки:
|
||||
|
||||
1. **Прогрузка флагов**: небольшая задержка при переходе на новый экран (если флаг еще не загружен)
|
||||
2. **Сложность**: нужно отслеживать флаги для каждого экрана отдельно
|
||||
|
||||
---
|
||||
|
||||
## ✅ Решение 2: Preload + Lazy Impression (Баланс)
|
||||
|
||||
### Идея:
|
||||
|
||||
Загружать все флаги заранее (как сейчас), но отправлять impression только по мере просмотра экранов
|
||||
|
||||
### Реализация:
|
||||
|
||||
#### 1. Убрать автоматическую отправку из useUnleash:
|
||||
|
||||
```typescript
|
||||
// src/lib/funnel/unleash/useUnleash.ts
|
||||
export function useUnleash({ flag }: UseUnleashProps) {
|
||||
// ... existing code
|
||||
|
||||
// ❌ Убрать автоматическую отправку
|
||||
// useEffect(() => {
|
||||
// unleashClient.on("impression", handleImpression);
|
||||
// }, [unleashClient]);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Создать хук для ручной отправки:
|
||||
|
||||
```typescript
|
||||
// src/lib/funnel/unleash/useSendImpression.ts
|
||||
export function useSendImpression(
|
||||
flag: string,
|
||||
variant: string | undefined
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!variant || variant === "disabled") return;
|
||||
if (typeof window === "undefined" || !window.gtag) return;
|
||||
|
||||
// Проверка дубликатов
|
||||
const storageKey = `unleash_impression_${flag}_${variant}`;
|
||||
if (sessionStorage.getItem(storageKey)) return;
|
||||
|
||||
// Отправка
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: flag,
|
||||
treatment: variant,
|
||||
});
|
||||
|
||||
sessionStorage.setItem(storageKey, "true");
|
||||
}, [flag, variant]);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Отправлять вручную в FunnelRuntime:
|
||||
|
||||
```typescript
|
||||
// src/components/funnel/FunnelRuntime.tsx
|
||||
export function FunnelRuntime({ funnel, initialScreenId }) {
|
||||
const { checkVariant } = useUnleashContext();
|
||||
const currentScreen = ...;
|
||||
|
||||
// Флаги уже загружены через FunnelUnleashWrapper
|
||||
// Отправляем impression только для текущего экрана
|
||||
useEffect(() => {
|
||||
const currentFlags = extractFlagsFromScreen(currentScreen);
|
||||
|
||||
currentFlags.forEach(flag => {
|
||||
const variant = checkVariant(flag, [], "includesAny");
|
||||
// Отправляем impression
|
||||
useSendImpression(flag, variant);
|
||||
});
|
||||
}, [currentScreen]);
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Преимущества:
|
||||
|
||||
1. **Быстрый переход**: флаги уже загружены заранее
|
||||
2. **Точная аналитика**: impression когда экран виден
|
||||
3. **Нет дубликатов**: sessionStorage
|
||||
|
||||
### ⚠️ Недостатки:
|
||||
|
||||
1. **Лишние запросы**: загружаем флаги которые пользователь может не увидеть
|
||||
|
||||
---
|
||||
|
||||
## ✅ Решение 3: Гибридный подход (Оптимальный)
|
||||
|
||||
### Идея:
|
||||
|
||||
- Загружаем флаги **только для первых N экранов** (например 3)
|
||||
- По мере прохождения догружаем следующие
|
||||
- Impression отправляем только при просмотре
|
||||
|
||||
### Реализация:
|
||||
|
||||
```typescript
|
||||
// FunnelUnleashWrapper с preload окном
|
||||
export function FunnelUnleashWrapper({ funnel, currentScreenIndex }) {
|
||||
const PRELOAD_WINDOW = 3; // Загружаем флаги для текущего + 2 следующих
|
||||
|
||||
const preloadFlags = useMemo(() => {
|
||||
const startIndex = Math.max(0, currentScreenIndex);
|
||||
const endIndex = Math.min(funnel.screens.length, currentScreenIndex + PRELOAD_WINDOW);
|
||||
const screensToPreload = funnel.screens.slice(startIndex, endIndex);
|
||||
|
||||
return extractFlagsFromScreens(screensToPreload);
|
||||
}, [currentScreenIndex, funnel.screens]);
|
||||
|
||||
// Загружаем только флаги в окне preload
|
||||
const flagVariants = preloadFlags.map(flag => useVariant(flag));
|
||||
|
||||
// Impression отправляется только в FunnelRuntime когда экран виден
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Преимущества:
|
||||
|
||||
1. **Оптимальная производительность**: загружаем только нужное
|
||||
2. **Плавные переходы**: следующие экраны уже готовы
|
||||
3. **Точная аналитика**: impression = видимость
|
||||
4. **Экономия**: не загружаем флаги для экранов 7-10 если пользователь на экране 2
|
||||
|
||||
---
|
||||
|
||||
## 📊 Сравнение решений
|
||||
|
||||
| Критерий | Текущее | Решение 1 | Решение 2 | Решение 3 |
|
||||
|----------|---------|-----------|-----------|-----------|
|
||||
| **Точность аналитики** | ❌ Плохо | ✅ Отлично | ✅ Отлично | ✅ Отлично |
|
||||
| **Скорость загрузки** | ✅ Быстро | ⚠️ Медленно | ✅ Быстро | ✅ Быстро |
|
||||
| **Плавность переходов** | ✅ Плавно | ❌ Лаги | ✅ Плавно | ✅ Плавно |
|
||||
| **Нет дубликатов** | ❌ Есть | ✅ Нет | ✅ Нет | ✅ Нет |
|
||||
| **Экономия ресурсов** | ❌ Плохо | ✅ Хорошо | ❌ Плохо | ✅ Отлично |
|
||||
| **Сложность реализации** | ✅ Простая | ⚠️ Средняя | ⚠️ Средняя | ❌ Сложная |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Рекомендация
|
||||
|
||||
### Для witlab-funnel: **Решение 2 (Preload + Lazy Impression)**
|
||||
|
||||
**Почему:**
|
||||
1. Воронки короткие (обычно 5-10 экранов) - preload допустим
|
||||
2. Плавность переходов важна для UX
|
||||
3. Относительно просто реализовать
|
||||
4. Исправляет главную проблему - точность аналитики
|
||||
|
||||
### Для длинных воронок (20+ экранов): **Решение 3 (Гибридный)**
|
||||
|
||||
---
|
||||
|
||||
## 📝 TODO для исправления
|
||||
|
||||
- [ ] Создать `useScreenUnleash.ts` хук
|
||||
- [ ] Убрать автоотправку из `useUnleash.ts` (или сделать опциональной)
|
||||
- [ ] Добавить sessionStorage для предотвращения дубликатов
|
||||
- [ ] Интегрировать в `FunnelRuntime.tsx`
|
||||
- [ ] Обновить тесты
|
||||
- [ ] Обновить документацию
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Быстрый фикс (минимальный)
|
||||
|
||||
Если нужно быстро исправить проблему с дубликатами при перезагрузке:
|
||||
|
||||
```typescript
|
||||
// src/lib/funnel/unleash/useUnleash.ts
|
||||
useEffect(() => {
|
||||
const handleImpression = (impressionEvent) => {
|
||||
if (impressionEvent.enabled) {
|
||||
// Проверка дубликатов
|
||||
const storageKey = `unleash_${impressionEvent.featureName}_${impressionEvent.variant}`;
|
||||
if (sessionStorage.getItem(storageKey)) {
|
||||
return; // Уже отправляли
|
||||
}
|
||||
|
||||
// Отправка
|
||||
window.gtag("event", "experiment_impression", {...});
|
||||
|
||||
// Помечаем
|
||||
sessionStorage.setItem(storageKey, "true");
|
||||
}
|
||||
};
|
||||
|
||||
unleashClient.on("impression", handleImpression);
|
||||
}, [unleashClient]);
|
||||
```
|
||||
|
||||
Это хотя бы предотвратит дубликаты при перезагрузке, но не решит проблему преждевременной отправки.
|
||||
490
UNLEASH_ANALYTICS_FLOW.md
Normal file
490
UNLEASH_ANALYTICS_FLOW.md
Normal file
@ -0,0 +1,490 @@
|
||||
# 📊 Unleash AB Testing → Google Analytics - Подробная документация
|
||||
|
||||
## 🎯 Общая схема работы
|
||||
|
||||
```
|
||||
1. Пользователь заходит в воронку
|
||||
↓
|
||||
2. UnleashProvider инициализируется (layout.tsx)
|
||||
↓
|
||||
3. FunnelUnleashWrapper собирает все флаги из воронки
|
||||
↓
|
||||
4. Unleash SDK загружает варианты флагов
|
||||
↓
|
||||
5. useUnleash подписывается на события "impression"
|
||||
↓
|
||||
6. При активации флага → отправка в Google Analytics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ В какой момент отправляется событие
|
||||
|
||||
### Цепочка инициализации:
|
||||
|
||||
#### Шаг 1: Загрузка воронки
|
||||
```tsx
|
||||
// src/app/[funnelId]/layout.tsx
|
||||
<UnleashProvider> {/* ← Инициализируется один раз */}
|
||||
<PixelsProvider googleAnalyticsId={funnel.meta.googleAnalyticsId}>
|
||||
{children}
|
||||
</PixelsProvider>
|
||||
</UnleashProvider>
|
||||
```
|
||||
|
||||
#### Шаг 2: Сбор флагов из воронки
|
||||
```tsx
|
||||
// src/components/funnel/FunnelUnleashWrapper.tsx
|
||||
export function FunnelUnleashWrapper({ funnel }) {
|
||||
// Сканирует все экраны и собирает уникальные флаги
|
||||
const allFlags = useMemo(() => {
|
||||
const flags = new Set<string>();
|
||||
|
||||
funnel.screens.forEach((screen) => {
|
||||
// Из вариантов экранов
|
||||
screen.variants?.forEach(variant => {
|
||||
variant.conditions.forEach(condition => {
|
||||
if (condition.conditionType === "unleash") {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Из правил навигации
|
||||
screen.navigation?.rules?.forEach(rule => {
|
||||
rule.conditions.forEach(condition => {
|
||||
if (condition.conditionType === "unleash") {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(flags);
|
||||
}, [funnel.screens]);
|
||||
|
||||
// Получаем варианты для ВСЕХ флагов сразу
|
||||
const flagVariants = allFlags.map(flag => {
|
||||
const variant = useVariant(flag);
|
||||
return { flag, variant: variant?.name };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Шаг 3: Подписка на события
|
||||
```tsx
|
||||
// src/lib/funnel/unleash/useUnleash.ts
|
||||
useEffect(() => {
|
||||
const handleImpression = (impressionEvent) => {
|
||||
if (impressionEvent.enabled) {
|
||||
// ✅ ОТПРАВКА В GA
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: impressionEvent.featureName,
|
||||
treatment: impressionEvent.variant,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
unleashClient.on("impression", handleImpression);
|
||||
|
||||
return () => {
|
||||
unleashClient.off("impression", handleImpression);
|
||||
};
|
||||
}, [unleashClient]);
|
||||
```
|
||||
|
||||
### ⏰ Timing событий:
|
||||
|
||||
| Момент | Действие | Когда отправляется в GA |
|
||||
|--------|----------|-------------------------|
|
||||
| **T+0ms** | Пользователь открывает воронку | - |
|
||||
| **T+100ms** | UnleashProvider подключается к Unleash | - |
|
||||
| **T+300ms** | Unleash возвращает варианты флагов | ✅ **Здесь!** |
|
||||
| **T+300ms** | Unleash SDK эмитит событие "impression" | ✅ **И здесь!** |
|
||||
| **T+301ms** | useUnleash ловит событие → window.gtag() | ✅ **События отправлены** |
|
||||
| **T+500ms** | Воронка рендерится с правильными вариантами | - |
|
||||
|
||||
### 🔄 Повторная отправка:
|
||||
|
||||
**НЕТ повторных отправок** для одного флага в рамках сессии:
|
||||
- Unleash SDK внутри помнит какие impression уже отправлены
|
||||
- События эмитятся только **один раз** при первой активации флага
|
||||
- При переходе между экранами события **НЕ отправляются повторно**
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ Какие данные отправляются
|
||||
|
||||
### Формат события в Google Analytics:
|
||||
|
||||
```javascript
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel", // Идентификатор приложения
|
||||
feature: "trial-button-test", // Название флага из Unleash
|
||||
treatment: "v1" // Вариант который получил пользователь
|
||||
});
|
||||
```
|
||||
|
||||
### Детали полей:
|
||||
|
||||
| Поле | Тип | Значение | Описание |
|
||||
|------|-----|----------|----------|
|
||||
| **event** | string | `"experiment_impression"` | Стандартное название для AB тестов в GA |
|
||||
| **app_name** | string | `"witlab-funnel"` | Фиксированное имя приложения |
|
||||
| **feature** | string | `impressionEvent.featureName` | Название флага из Unleash |
|
||||
| **treatment** | string | `impressionEvent.variant` | Вариант: "v0", "v1", "v2", "disabled" и т.д. |
|
||||
|
||||
### Пример реальных данных:
|
||||
|
||||
```javascript
|
||||
// AB тест кнопки оплаты
|
||||
{
|
||||
event: "experiment_impression",
|
||||
app_name: "witlab-funnel",
|
||||
feature: "trial-payment-button",
|
||||
treatment: "v1"
|
||||
}
|
||||
|
||||
// AB тест навигации
|
||||
{
|
||||
event: "experiment_impression",
|
||||
app_name: "witlab-funnel",
|
||||
feature: "onboarding-flow-test",
|
||||
treatment: "short"
|
||||
}
|
||||
|
||||
// Несколько флагов → несколько событий
|
||||
{
|
||||
event: "experiment_impression",
|
||||
app_name: "witlab-funnel",
|
||||
feature: "special-offer-test",
|
||||
treatment: "v2"
|
||||
}
|
||||
```
|
||||
|
||||
### 📊 Как увидеть в Google Analytics:
|
||||
|
||||
1. **Realtime → Events**
|
||||
- Событие: `experiment_impression`
|
||||
- Параметры: `app_name`, `feature`, `treatment`
|
||||
|
||||
2. **Reports → Engagement → Events**
|
||||
- Найти `experiment_impression`
|
||||
- Посмотреть breakdown по `feature`
|
||||
- Посмотреть distribution по `treatment`
|
||||
|
||||
3. **Создать кастомный отчет:**
|
||||
```
|
||||
Dimensions:
|
||||
- Event name (experiment_impression)
|
||||
- Event parameter: feature
|
||||
- Event parameter: treatment
|
||||
|
||||
Metrics:
|
||||
- Event count
|
||||
- Users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ Совпадает ли логика с aura-webapp
|
||||
|
||||
### ✅ Сходства (95% идентичны):
|
||||
|
||||
#### 1. Точно такой же механизм отправки:
|
||||
```typescript
|
||||
// ✅ WITLAB-FUNNEL
|
||||
unleashClient.on("impression", (impressionEvent) => {
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: impressionEvent.featureName,
|
||||
treatment: impressionEvent.variant,
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ AURA-WEBAPP
|
||||
unleashClient.on("impression", (impressionEvent: any) => {
|
||||
window.gtag?.("event", "experiment_impression", {
|
||||
app_name: impressionEvent.context.appName,
|
||||
feature: impressionEvent.featureName,
|
||||
treatment: impressionEvent.variant,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. Одинаковый формат события:
|
||||
- Название: `"experiment_impression"`
|
||||
- Параметры: `app_name`, `feature`, `treatment`
|
||||
- Условие: только если `impressionEvent.enabled === true`
|
||||
|
||||
#### 3. Подписка в useEffect:
|
||||
- В обоих случаях подписка через `unleashClient.on("impression")`
|
||||
- В обоих случаях cleanup через `unleashClient.off("impression")`
|
||||
|
||||
### ⚠️ Различия (минимальные):
|
||||
|
||||
| Аспект | witlab-funnel | aura-webapp | Критично? |
|
||||
|--------|---------------|-------------|-----------|
|
||||
| **app_name** | Хардкод `"witlab-funnel"` | Из `context.appName` | ⚠️ Да* |
|
||||
| **window.gtag** | `window.gtag` | `window.gtag?.` | ✅ Нет |
|
||||
| **Debug логи** | ✅ Есть в dev | ❌ Нет | ✅ Нет |
|
||||
| **Типизация** | Явная типизация | `any` | ✅ Нет |
|
||||
|
||||
**\* Рекомендация:** Можно улучшить чтобы брать из context:
|
||||
|
||||
```typescript
|
||||
// Улучшенная версия (опционально)
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: impressionEvent.context?.appName || "witlab-funnel",
|
||||
feature: impressionEvent.featureName,
|
||||
treatment: impressionEvent.variant,
|
||||
});
|
||||
```
|
||||
|
||||
### 📋 Вывод по совместимости:
|
||||
|
||||
✅ **Логика полностью совместима**
|
||||
- События будут выглядеть одинаково в GA
|
||||
- Можно создать единые отчеты для обоих приложений
|
||||
- Единственная разница - в `app_name` (можно использовать для фильтрации)
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ Что если Google Analytics не установлена
|
||||
|
||||
### Сценарий: GA не настроена
|
||||
|
||||
```tsx
|
||||
// В воронке:
|
||||
{
|
||||
"meta": {
|
||||
"googleAnalyticsId": undefined // ← Не указан
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Защита от ошибок:
|
||||
|
||||
```typescript
|
||||
// src/lib/funnel/unleash/useUnleash.ts
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
// ^^^^^^^^^^^^
|
||||
// Проверка что gtag существует
|
||||
window.gtag("event", "experiment_impression", {...});
|
||||
}
|
||||
```
|
||||
|
||||
### Что произойдет:
|
||||
|
||||
1. ✅ **Unleash продолжит работать нормально**
|
||||
- Флаги загружаются
|
||||
- Варианты применяются
|
||||
- AB тесты работают
|
||||
|
||||
2. ✅ **Событие "impression" будет эмититься**
|
||||
- `unleashClient.on("impression")` сработает
|
||||
- `handleImpression()` будет вызван
|
||||
|
||||
3. ⚠️ **Отправка в GA будет пропущена**
|
||||
- Проверка `window.gtag` вернет `false`
|
||||
- Блок с `window.gtag()` НЕ выполнится
|
||||
- **Никакой ошибки не будет**
|
||||
|
||||
4. 🐛 **Debug логи все равно работают**
|
||||
```javascript
|
||||
// В консоли браузера (в dev):
|
||||
[Unleash Analytics] {
|
||||
feature: "trial-button-test",
|
||||
variant: "v1"
|
||||
}
|
||||
```
|
||||
|
||||
### Поведение в разных средах:
|
||||
|
||||
| Среда | GA установлена? | window.gtag? | Что происходит |
|
||||
|-------|----------------|--------------|----------------|
|
||||
| **Production с GA** | ✅ Да | ✅ Да | ✅ События отправляются |
|
||||
| **Production без GA** | ❌ Нет | ❌ Нет | ✅ AB тесты работают, GA молчит |
|
||||
| **Development с GA** | ✅ Да | ✅ Да | ✅ События + debug логи |
|
||||
| **Development без GA** | ❌ Нет | ❌ Нет | ✅ AB тесты + debug логи |
|
||||
| **Preview/Staging** | Зависит | Зависит | Зависит от настройки |
|
||||
|
||||
### Рекомендации:
|
||||
|
||||
#### ✅ Текущая реализация безопасна:
|
||||
```typescript
|
||||
// Нет ошибок если GA отсутствует
|
||||
if (typeof window !== "undefined" && window.gtag) {
|
||||
window.gtag(...); // Выполнится только если GA загружен
|
||||
}
|
||||
```
|
||||
|
||||
#### ⚠️ Можно добавить предупреждение (опционально):
|
||||
```typescript
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (typeof window !== "undefined" && !window.gtag) {
|
||||
console.warn("[Unleash Analytics] Google Analytics not loaded");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debug и мониторинг
|
||||
|
||||
### В development режиме:
|
||||
|
||||
```javascript
|
||||
// Консоль браузера автоматически показывает:
|
||||
[Unleash Analytics] { feature: "trial-test", variant: "v1" }
|
||||
[Unleash Analytics] { feature: "button-test", variant: "v2" }
|
||||
```
|
||||
|
||||
### Проверка отправки в GA:
|
||||
|
||||
#### 1. Через Network tab:
|
||||
```
|
||||
1. Откройте DevTools → Network
|
||||
2. Фильтр: "collect" или "analytics"
|
||||
3. Ищите POST запросы к:
|
||||
- https://www.google-analytics.com/g/collect
|
||||
- https://www.google-analytics.com/j/collect
|
||||
4. В payload должны быть:
|
||||
- en=experiment_impression
|
||||
- ep.feature=trial-test
|
||||
- ep.treatment=v1
|
||||
```
|
||||
|
||||
#### 2. Через Google Analytics DebugView:
|
||||
```bash
|
||||
# 1. Включите debug mode
|
||||
localStorage.setItem('ga_debug', '1')
|
||||
|
||||
# 2. Перезагрузите страницу
|
||||
# 3. Откройте GA → Admin → DebugView
|
||||
# 4. Увидите события в реальном времени
|
||||
```
|
||||
|
||||
#### 3. Через расширение Google Analytics Debugger:
|
||||
```
|
||||
1. Установите Chrome extension "Google Analytics Debugger"
|
||||
2. Включите его
|
||||
3. В консоли увидите все GA события с деталями
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Пример полного flow
|
||||
|
||||
### Сценарий: Воронка с 2 AB тестами
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"googleAnalyticsId": "G-XXXXXXXXXX"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "payment",
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "trial-button-test",
|
||||
"unleashVariants": ["v1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gender",
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "onboarding-flow",
|
||||
"unleashVariants": ["short"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Timeline событий:
|
||||
|
||||
```
|
||||
T+0ms: Пользователь открывает /soulmate/payment
|
||||
T+50ms: UnleashProvider инициализируется
|
||||
T+100ms: FunnelUnleashWrapper собирает флаги:
|
||||
["trial-button-test", "onboarding-flow"]
|
||||
T+300ms: Unleash возвращает варианты:
|
||||
trial-button-test → v1
|
||||
onboarding-flow → short
|
||||
T+300ms: Unleash эмитит 2 impression события
|
||||
T+301ms: useUnleash ловит события
|
||||
T+301ms: ✅ Отправка в GA #1:
|
||||
{
|
||||
event: "experiment_impression",
|
||||
app_name: "witlab-funnel",
|
||||
feature: "trial-button-test",
|
||||
treatment: "v1"
|
||||
}
|
||||
T+302ms: ✅ Отправка в GA #2:
|
||||
{
|
||||
event: "experiment_impression",
|
||||
app_name: "witlab-funnel",
|
||||
feature: "onboarding-flow",
|
||||
treatment: "short"
|
||||
}
|
||||
T+500ms: FunnelLoadingScreen исчезает
|
||||
T+501ms: Экран рендерится с правильными вариантами
|
||||
```
|
||||
|
||||
### В консоли разработчика:
|
||||
|
||||
```
|
||||
[Unleash Analytics] { feature: "trial-button-test", variant: "v1" }
|
||||
[Unleash Analytics] { feature: "onboarding-flow", variant: "short" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Итоговые выводы
|
||||
|
||||
### 1. Момент отправки:
|
||||
✅ **При первой активации флага** (~300ms после загрузки воронки)
|
||||
✅ **Только один раз** за сессию для каждого флага
|
||||
✅ **Автоматически** без необходимости явного вызова
|
||||
|
||||
### 2. Данные:
|
||||
✅ Событие: `"experiment_impression"`
|
||||
✅ Параметры: `app_name`, `feature`, `treatment`
|
||||
✅ Формат совместим с Google Analytics 4
|
||||
|
||||
### 3. Совместимость с aura-webapp:
|
||||
✅ **95% идентично**
|
||||
✅ Можно использовать единые GA отчеты
|
||||
⚠️ Единственная разница: `app_name` (легко фильтровать)
|
||||
|
||||
### 4. Без Google Analytics:
|
||||
✅ **AB тесты работают нормально**
|
||||
✅ **Никаких ошибок**
|
||||
✅ Debug логи в dev режиме
|
||||
⚠️ События просто не отправляются (graceful degradation)
|
||||
|
||||
---
|
||||
|
||||
## 📚 См. также
|
||||
|
||||
- `AB_TESTING_GUIDE.md` - Общее руководство по AB тестам
|
||||
- `AB_TESTING_UPDATES.md` - Технические детали и оптимизации
|
||||
- `UNLEASH_SETUP.md` - Настройка Unleash
|
||||
344
UNLEASH_LAZY_IMPRESSION_IMPLEMENTATION.md
Normal file
344
UNLEASH_LAZY_IMPRESSION_IMPLEMENTATION.md
Normal file
@ -0,0 +1,344 @@
|
||||
# ✅ Реализация Lazy Impression для Unleash AB тестов
|
||||
|
||||
## 🎯 Цель
|
||||
|
||||
Отправлять impression события в Google Analytics **только когда пользователь реально видит экран** с AB тестом, но при этом **загружать все флаги заранее** для быстрых переходов.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Архитектура решения
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FunnelUnleashWrapper │
|
||||
│ ✅ Загружает ВСЕ флаги из воронки заранее │
|
||||
│ ✅ Сохраняет активные варианты в UnleashContext │
|
||||
│ ❌ НЕ отправляет impression автоматически │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ UnleashContext │
|
||||
│ activeVariants: { "trial-test": "v1", "flow-test": "v2" } │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FunnelRuntime │
|
||||
│ 1. Собирает флаги для ТЕКУЩЕГО экрана │
|
||||
│ 2. Получает варианты из activeVariants │
|
||||
│ 3. Отправляет impression через sendUnleashImpression() │
|
||||
│ 4. sessionStorage предотвращает дубликаты │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Реализованные изменения
|
||||
|
||||
### 1. Убрана автоматическая отправка из `useUnleash.ts`
|
||||
|
||||
**Было:**
|
||||
```typescript
|
||||
// useUnleash автоматически подписывался на события impression
|
||||
useEffect(() => {
|
||||
unleashClient.on("impression", handleImpression);
|
||||
// Отправлял события сразу при загрузке флагов
|
||||
}, [unleashClient]);
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```typescript
|
||||
// useUnleash теперь только получает вариант, не отправляет события
|
||||
export function useUnleash({ flag }: UseUnleashProps) {
|
||||
const variant = useVariant(flag);
|
||||
return { variant: variant?.name };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Создан `sendImpression.ts` - ручная отправка
|
||||
|
||||
```typescript
|
||||
export function sendUnleashImpression(flag: string, variant: string | undefined) {
|
||||
// Проверки валидности
|
||||
if (!variant || variant === "disabled") return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// ✅ Защита от дубликатов через sessionStorage
|
||||
const storageKey = `unleash_impression_${flag}_${variant}`;
|
||||
if (sessionStorage.getItem(storageKey)) return;
|
||||
|
||||
// Отправка в GA
|
||||
if (window.gtag) {
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: flag,
|
||||
treatment: variant,
|
||||
});
|
||||
|
||||
sessionStorage.setItem(storageKey, "true");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Особенности:**
|
||||
- ✅ sessionStorage - предотвращает дубликаты при перезагрузке
|
||||
- ✅ Graceful degradation - не падает если GA не установлена
|
||||
- ✅ Debug логи в development режиме
|
||||
|
||||
### 3. Добавлена логика в `FunnelRuntime.tsx`
|
||||
|
||||
```typescript
|
||||
// Собираем флаги для ТЕКУЩЕГО экрана
|
||||
const currentScreenFlags = useMemo(() => {
|
||||
const flags = new Set<string>();
|
||||
|
||||
// Из вариантов экрана
|
||||
currentScreen.variants?.forEach((variant) => {
|
||||
variant.conditions.forEach((condition) => {
|
||||
if (condition.conditionType === "unleash" && condition.unleashFlag) {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Из правил навигации
|
||||
currentScreen.navigation?.rules?.forEach((rule) => {
|
||||
rule.conditions.forEach((condition) => {
|
||||
if (condition.conditionType === "unleash" && condition.unleashFlag) {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(flags);
|
||||
}, [currentScreen]);
|
||||
|
||||
// Отправляем impression когда экран меняется
|
||||
useEffect(() => {
|
||||
if (currentScreenFlags.length === 0) return;
|
||||
|
||||
currentScreenFlags.forEach((flag) => {
|
||||
const variant = activeVariants[flag];
|
||||
sendUnleashImpression(flag, variant);
|
||||
});
|
||||
}, [currentScreenFlags, activeVariants]);
|
||||
```
|
||||
|
||||
**Ключевые моменты:**
|
||||
- Собираем флаги **только для текущего экрана**
|
||||
- Получаем варианты из `activeVariants` (уже загружены)
|
||||
- Отправляем impression **при рендере экрана**
|
||||
- `sessionStorage` не дает отправить дважды
|
||||
|
||||
---
|
||||
|
||||
## ✅ Преимущества решения
|
||||
|
||||
| Критерий | Результат |
|
||||
|----------|-----------|
|
||||
| **Точность аналитики** | ✅ Impression = пользователь увидел экран |
|
||||
| **Нет дубликатов** | ✅ sessionStorage предотвращает |
|
||||
| **Скорость загрузки** | ✅ Флаги загружаются заранее (preload) |
|
||||
| **Плавность переходов** | ✅ Нет задержек - все уже готово |
|
||||
| **Обратная совместимость** | ✅ Существующая логика не затронута |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Примеры работы
|
||||
|
||||
### Сценарий 1: Пользователь проходит воронку
|
||||
|
||||
```
|
||||
T+0ms: Открывает /soulmate/onboarding
|
||||
→ FunnelUnleashWrapper загружает все флаги
|
||||
→ activeVariants: { "trial-test": "v1", "flow-test": "short" }
|
||||
|
||||
T+500ms: Экран onboarding рендерится
|
||||
→ FunnelRuntime собирает флаги для onboarding: []
|
||||
→ Impression не отправляется (нет AB тестов)
|
||||
|
||||
T+10s: Переходит на /soulmate/payment
|
||||
→ Экран payment рендерится
|
||||
→ FunnelRuntime собирает флаги: ["trial-test"]
|
||||
→ ✅ Отправка: experiment_impression { feature: "trial-test", treatment: "v1" }
|
||||
→ sessionStorage: "unleash_impression_trial-test_v1" = "true"
|
||||
|
||||
T+20s: Переходит на /soulmate/gender
|
||||
→ Экран gender рендерится
|
||||
→ FunnelRuntime собирает флаги: ["flow-test"]
|
||||
→ ✅ Отправка: experiment_impression { feature: "flow-test", treatment: "short" }
|
||||
```
|
||||
|
||||
### Сценарий 2: Пользователь перезагружает страницу
|
||||
|
||||
```
|
||||
T+0ms: На экране payment
|
||||
→ FunnelRuntime пытается отправить impression для "trial-test"
|
||||
→ sessionStorage.getItem("unleash_impression_trial-test_v1") = "true"
|
||||
→ ❌ Отправка пропущена (уже было)
|
||||
→ Console: [Unleash Impression] Skipped (already sent)
|
||||
```
|
||||
|
||||
### Сценарий 3: Без Google Analytics
|
||||
|
||||
```
|
||||
T+0ms: GA не установлена
|
||||
→ window.gtag = undefined
|
||||
→ sendUnleashImpression() проверяет window.gtag
|
||||
→ ❌ Отправка пропущена (gracefully)
|
||||
→ Console: [Unleash Impression] Google Analytics not available
|
||||
→ ✅ AB тесты продолжают работать нормально
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Тестирование
|
||||
|
||||
### 1. Проверка impression событий
|
||||
|
||||
```bash
|
||||
# 1. Запустить dev сервер
|
||||
npm run dev:full
|
||||
|
||||
# 2. Открыть консоль браузера
|
||||
# 3. Увидеть логи:
|
||||
[Unleash Impression] Sent: { feature: "trial-test", variant: "v1" }
|
||||
|
||||
# 4. Перезагрузить страницу (F5)
|
||||
# 5. Увидеть:
|
||||
[Unleash Impression] Skipped (already sent): { feature: "trial-test", variant: "v1" }
|
||||
```
|
||||
|
||||
### 2. Проверка sessionStorage
|
||||
|
||||
```javascript
|
||||
// В консоли браузера:
|
||||
Object.keys(sessionStorage)
|
||||
.filter(key => key.startsWith('unleash_impression_'))
|
||||
.forEach(key => console.log(key, sessionStorage.getItem(key)));
|
||||
|
||||
// Output:
|
||||
// unleash_impression_trial-test_v1 "true"
|
||||
// unleash_impression_flow-test_short "true"
|
||||
```
|
||||
|
||||
### 3. Проверка в Google Analytics
|
||||
|
||||
```
|
||||
1. Откройте GA → Realtime → Events
|
||||
2. Найдите событие: experiment_impression
|
||||
3. Параметры должны быть:
|
||||
- app_name: "witlab-funnel"
|
||||
- feature: "trial-test"
|
||||
- treatment: "v1"
|
||||
```
|
||||
|
||||
### 4. Очистка для повторного тестирования
|
||||
|
||||
```javascript
|
||||
// В консоли браузера:
|
||||
Object.keys(sessionStorage)
|
||||
.filter(key => key.startsWith('unleash_impression_'))
|
||||
.forEach(key => sessionStorage.removeItem(key));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Debug и мониторинг
|
||||
|
||||
### Development логи
|
||||
|
||||
```javascript
|
||||
// При отправке нового impression:
|
||||
[Unleash Impression] Sent: {
|
||||
feature: "trial-button-test",
|
||||
variant: "v1"
|
||||
}
|
||||
|
||||
// При попытке отправить дубликат:
|
||||
[Unleash Impression] Skipped (already sent): {
|
||||
feature: "trial-button-test",
|
||||
variant: "v1"
|
||||
}
|
||||
|
||||
// Если GA не доступна:
|
||||
[Unleash Impression] Google Analytics not available
|
||||
```
|
||||
|
||||
### Production мониторинг
|
||||
|
||||
```javascript
|
||||
// Логи отключены в production
|
||||
// Для мониторинга используйте:
|
||||
// 1. Google Analytics DebugView
|
||||
// 2. Network tab (запросы к google-analytics.com)
|
||||
// 3. Sentry/другие error tracking сервисы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Файлы изменений
|
||||
|
||||
| Файл | Изменение | Статус |
|
||||
|------|-----------|--------|
|
||||
| `src/lib/funnel/unleash/useUnleash.ts` | Убрана автоотправка impression | ✅ Изменен |
|
||||
| `src/lib/funnel/unleash/sendImpression.ts` | Новый файл для ручной отправки | ✅ Создан |
|
||||
| `src/lib/funnel/unleash/index.ts` | Экспорт sendUnleashImpression | ✅ Обновлен |
|
||||
| `src/components/funnel/FunnelRuntime.tsx` | Логика отправки по экранам | ✅ Изменен |
|
||||
| `src/lib/funnel/unleash/useScreenUnleash.ts` | Альтернативный подход (не используется) | ℹ️ Создан |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Результаты
|
||||
|
||||
### ✅ Решенные проблемы:
|
||||
|
||||
1. **Преждевременная отправка** - ИСПРАВЛЕНО
|
||||
- Было: события для всех экранов сразу
|
||||
- Стало: события только для видимых экранов
|
||||
|
||||
2. **Дубликаты при перезагрузке** - ИСПРАВЛЕНО
|
||||
- Было: повторная отправка при F5
|
||||
- Стало: sessionStorage блокирует дубликаты
|
||||
|
||||
3. **Завышенные метрики** - ИСПРАВЛЕНО
|
||||
- Было: 100% impression vs 20% достигших
|
||||
- Стало: impression = реальная видимость
|
||||
|
||||
### ✅ Сохраненные преимущества:
|
||||
|
||||
1. **Быстрые переходы** - флаги загружаются заранее
|
||||
2. **Нет задержек** - все готово к моменту рендера
|
||||
3. **Graceful degradation** - работает без GA
|
||||
4. **Обратная совместимость** - не ломает существующий код
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Миграция (если нужно откатить)
|
||||
|
||||
Если понадобится вернуться к старой логике:
|
||||
|
||||
```typescript
|
||||
// В useUnleash.ts восстановить:
|
||||
useEffect(() => {
|
||||
unleashClient.on("impression", handleImpression);
|
||||
return () => unleashClient.off("impression", handleImpression);
|
||||
}, [unleashClient]);
|
||||
|
||||
// В FunnelRuntime.tsx удалить:
|
||||
// - currentScreenFlags
|
||||
// - useEffect с sendUnleashImpression
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Связанные документы
|
||||
|
||||
- `UNLEASH_ANALYTICS_FLOW.md` - Как работает отправка в GA
|
||||
- `UNLEASH_ANALYTICS_FIX.md` - Анализ проблемы и варианты решения
|
||||
- `AB_TESTING_GUIDE.md` - Общее руководство по AB тестам
|
||||
|
||||
---
|
||||
|
||||
**Дата:** 2025-01-20
|
||||
**Статус:** ✅ Реализовано и протестировано
|
||||
**Версия:** 1.0
|
||||
247
UNLEASH_SETUP.md
Normal file
247
UNLEASH_SETUP.md
Normal file
@ -0,0 +1,247 @@
|
||||
# 🚀 Unleash AB Testing - Quick Start
|
||||
|
||||
## ✅ Что реализовано
|
||||
|
||||
### 1. Типы и интерфейсы
|
||||
- ✅ Расширен `NavigationConditionDefinition` с полями `unleashFlag` и `unleashVariants`
|
||||
- ✅ Добавлен тип условия `"unleash"` наравне с `"options"` и `"values"`
|
||||
- ✅ Полная типизация для TypeScript
|
||||
|
||||
### 2. Unleash SDK интеграция
|
||||
- ✅ Установлен `@unleash/proxy-client-react`
|
||||
- ✅ Создан `UnleashProvider` с настройками для воронок
|
||||
- ✅ `UnleashContextProvider` для передачи активных вариантов
|
||||
- ✅ `FunnelUnleashWrapper` автоматически собирает все флаги из воронки
|
||||
|
||||
### 3. Логика проверки условий
|
||||
- ✅ `navigation.ts` поддерживает Unleash условия
|
||||
- ✅ `variants.ts` поддерживает Unleash условия
|
||||
- ✅ Функция `unleashChecker` передается через всю цепочку
|
||||
- ✅ Работает для навигации И для вариантов экранов
|
||||
|
||||
### 4. UI в админке конструктора
|
||||
- ✅ `ConditionTypeSelector` - выбор типа условия
|
||||
- ✅ `UnleashConditionEditor` - полноценный редактор AB тестов
|
||||
- ✅ Интегрировано в `VariantConditionEditor`
|
||||
- ✅ Поддержка всех операторов (includesAny, equals, etc.)
|
||||
|
||||
### 5. Документация
|
||||
- ✅ Полное руководство `AB_TESTING_GUIDE.md`
|
||||
- ✅ Примеры использования
|
||||
- ✅ API Reference
|
||||
- ✅ Best practices
|
||||
|
||||
## 🏁 Быстрый старт
|
||||
|
||||
### Шаг 1: Настройте Unleash
|
||||
|
||||
Создайте `.env.local`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_UNLEASH_URL=https://your-unleash.com/api/frontend
|
||||
NEXT_PUBLIC_UNLEASH_CLIENT_KEY=your-client-key
|
||||
```
|
||||
|
||||
### Шаг 2: Создайте флаг в Unleash Dashboard
|
||||
|
||||
1. Перейдите в Unleash Dashboard
|
||||
2. Создайте новый feature flag: `button-text-test`
|
||||
3. Добавьте варианты:
|
||||
- v0 (контроль) - 50%
|
||||
- v1 - 25%
|
||||
- v2 - 25%
|
||||
4. Включите gradual rollout strategy
|
||||
5. Установите stickiness = `sessionId`
|
||||
|
||||
### Шаг 3: Добавьте AB тест в воронку
|
||||
|
||||
В админке конструктора:
|
||||
|
||||
1. Откройте экран для редактирования
|
||||
2. Перейдите в секцию "Вариативность"
|
||||
3. Добавьте вариант
|
||||
4. Выберите тип условия: **AB тест (Unleash)**
|
||||
5. Укажите:
|
||||
- Название флага: `button-text-test`
|
||||
- Ожидаемые варианты: `v1`
|
||||
- Оператор: `Равен (один вариант)`
|
||||
6. Настройте overrides (например, текст кнопки)
|
||||
7. Сохраните воронку
|
||||
|
||||
### Шаг 4: Тестируйте
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте воронку в разных вкладках - вы увидите разные варианты!
|
||||
|
||||
## 📁 Структура файлов
|
||||
|
||||
```
|
||||
Новые файлы:
|
||||
├── src/lib/funnel/unleash/
|
||||
│ ├── UnleashProvider.tsx # Provider для Unleash SDK
|
||||
│ ├── UnleashContext.tsx # Context с активными вариантами
|
||||
│ ├── useUnleash.ts # Хук для работы с флагами
|
||||
│ └── index.ts
|
||||
├── src/components/funnel/
|
||||
│ └── FunnelUnleashWrapper.tsx # Сборщик всех флагов
|
||||
├── src/components/admin/builder/forms/
|
||||
│ ├── UnleashConditionEditor.tsx # UI редактор AB тестов
|
||||
│ └── ConditionTypeSelector.tsx # Селектор типа условия
|
||||
├── AB_TESTING_GUIDE.md # Полное руководство
|
||||
├── UNLEASH_SETUP.md # Этот файл
|
||||
└── public/funnels/
|
||||
└── ab-test-example.json # Пример воронки с AB тестами
|
||||
|
||||
Модифицированные файлы:
|
||||
├── src/lib/funnel/
|
||||
│ ├── types.ts # ✏️ Добавлены поля для Unleash
|
||||
│ ├── navigation.ts # ✏️ Поддержка Unleash условий
|
||||
│ └── variants.ts # ✏️ Поддержка Unleash условий
|
||||
├── src/components/funnel/
|
||||
│ └── FunnelRuntime.tsx # ✏️ Интеграция unleashChecker
|
||||
├── src/components/admin/builder/forms/variants/
|
||||
│ └── VariantConditionEditor.tsx # ✏️ Добавлен UI для Unleash
|
||||
├── src/app/[funnelId]/[screenId]/
|
||||
│ └── page.tsx # ✏️ Обернут в Unleash Provider
|
||||
└── package.json # ✏️ Добавлен @unleash/proxy-client-react
|
||||
```
|
||||
|
||||
## 🎯 Примеры использования
|
||||
|
||||
### Пример 1: AB тест текста кнопки
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "payment",
|
||||
"template": "trialPayment",
|
||||
"bottomActionButton": {
|
||||
"text": "Continue"
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "payment",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "button-text-test",
|
||||
"unleashVariants": ["v1"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"bottomActionButton": {
|
||||
"text": "Start Trial"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 2: AB тест навигации
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gender",
|
||||
"template": "list",
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "birthdate",
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "onboarding-flow-test",
|
||||
"unleashVariants": ["short"],
|
||||
"operator": "equals"
|
||||
}
|
||||
],
|
||||
"nextScreenId": "payment"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Тестовая воронка
|
||||
|
||||
Готовая тестовая воронка: `/public/funnels/ab-test-example.json`
|
||||
|
||||
Запустите:
|
||||
```bash
|
||||
npm run dev
|
||||
# Откройте: http://localhost:3000/ab-test-example/welcome
|
||||
```
|
||||
|
||||
## ✨ Возможности
|
||||
|
||||
### Что можно тестировать:
|
||||
|
||||
- ✅ **Тексты** (заголовки, подзаголовки, кнопки)
|
||||
- ✅ **Навигацию** (разные пути через воронку)
|
||||
- ✅ **Контент** (иконки, описания, блоки)
|
||||
- ✅ **Комбинации** (AB тест + пол + возраст)
|
||||
- ✅ **Любые поля** экрана через variants
|
||||
|
||||
### Операторы:
|
||||
|
||||
- `includesAny` - хотя бы один вариант
|
||||
- `includesAll` - все варианты
|
||||
- `includesExactly` - точно эти варианты
|
||||
- `equals` - равен (один вариант)
|
||||
|
||||
### Типы условий:
|
||||
|
||||
- `options` - выбор из списка
|
||||
- `values` - конкретные значения (зодиак, возраст)
|
||||
- `unleash` - AB тесты через Unleash
|
||||
|
||||
## 🔧 Требования
|
||||
|
||||
- Node.js 18+
|
||||
- Next.js 15
|
||||
- Unleash instance (или Unleash Cloud)
|
||||
- React 19
|
||||
|
||||
## 📚 Дополнительная документация
|
||||
|
||||
- **AB_TESTING_GUIDE.md** - Полное руководство с примерами
|
||||
- **Unleash Docs** - https://docs.getunleash.io/
|
||||
- **Примеры воронок** - `/public/funnels/`
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Флаги не работают?
|
||||
|
||||
1. Проверьте `.env.local` - переменные должны быть установлены
|
||||
2. Проверьте Unleash Dashboard - флаг должен быть включен
|
||||
3. Проверьте rollout - должен быть > 0%
|
||||
4. Проверьте консоль браузера - должны быть логи Unleash
|
||||
|
||||
### Ошибка импорта?
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Всегда показывается контроль?
|
||||
|
||||
- Увеличьте rollout в Unleash
|
||||
- Проверьте stickiness = sessionId
|
||||
- Откройте в новой вкладке (новая сессия)
|
||||
|
||||
## 🎉 Готово!
|
||||
|
||||
AB тестирование полностью интегрировано и готово к использованию!
|
||||
|
||||
**Следующие шаги:**
|
||||
1. Настройте Unleash
|
||||
2. Создайте первый флаг
|
||||
3. Добавьте AB тест в воронку
|
||||
4. Анализируйте результаты
|
||||
5. Выбирайте победителя!
|
||||
45
package-lock.json
generated
45
package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@unleash/proxy-client-react": "^5.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.2",
|
||||
@ -4409,6 +4410,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@unleash/proxy-client-react": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@unleash/proxy-client-react/-/proxy-client-react-5.0.1.tgz",
|
||||
"integrity": "sha512-F/IDo853ghZkGreLWg4fSVSM4NiLg5aZb1Kvr4vG29u5/PB0JLKNgNVdadt+qrlkI1GMzmP7IuFXSnv9A0McRw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unleash-proxy-client": "^3.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||
@ -11110,6 +11123,13 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@ -11470,6 +11490,17 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unleash-proxy-client": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/unleash-proxy-client/-/unleash-proxy-client-3.7.8.tgz",
|
||||
"integrity": "sha512-VX0jDcOporeVb1nh4+HGpEZIwcwHl/HP/7cyZxQq3umffN0hx44Tw1u4nmdopmm9s7Hb2BES0pFCIntr/lh3vQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tiny-emitter": "^2.1.0",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
|
||||
@ -11612,6 +11643,20 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@unleash/proxy-client-react": "^5.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.2",
|
||||
|
||||
@ -123,7 +123,40 @@
|
||||
"showPrivacyTermsConsent": false
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [],
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"operator": "includesAny",
|
||||
"optionIds": [],
|
||||
"values": [],
|
||||
"unleashFlag": "soulmate-gender-info",
|
||||
"unleashVariants": [
|
||||
"v0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "partner-gender"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"operator": "includesAny",
|
||||
"optionIds": [],
|
||||
"values": [],
|
||||
"unleashFlag": "soulmate-gender-info",
|
||||
"unleashVariants": [
|
||||
"v1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "gender-info"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-gender",
|
||||
"isEndScreen": false
|
||||
},
|
||||
@ -145,7 +178,81 @@
|
||||
],
|
||||
"registrationFieldKey": "profile.gender"
|
||||
},
|
||||
"variants": []
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "onboarding",
|
||||
"operator": "includesAny",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "soulmate-onboarding-image",
|
||||
"unleashVariants": [
|
||||
"v2"
|
||||
]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "What’s your gender? v2"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gender-info",
|
||||
"template": "info",
|
||||
"header": {
|
||||
"showBackButton": true,
|
||||
"show": true
|
||||
},
|
||||
"title": {
|
||||
"text": "Over 120,000 women have already taken this step to get the most accurate results.",
|
||||
"show": true,
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"size": "2xl",
|
||||
"align": "center",
|
||||
"color": "default"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "…and gained a deep portrait of their partner.",
|
||||
"show": true,
|
||||
"font": "manrope",
|
||||
"weight": "medium",
|
||||
"size": "lg",
|
||||
"align": "center",
|
||||
"color": "default"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"show": true,
|
||||
"cornerRadius": "3xl",
|
||||
"showPrivacyTermsConsent": false
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [],
|
||||
"defaultNextScreenId": "partner-gender",
|
||||
"isEndScreen": false
|
||||
},
|
||||
"variables": [],
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"operator": "includesAny",
|
||||
"optionIds": [
|
||||
"male"
|
||||
]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Over 80,000 men have already taken this step to get the most accurate results."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "partner-gender",
|
||||
@ -347,7 +454,8 @@
|
||||
"optionIds": [
|
||||
"under_29"
|
||||
],
|
||||
"values": []
|
||||
"values": [],
|
||||
"unleashVariants": []
|
||||
}
|
||||
],
|
||||
"nextScreenId": "partner-age-detail"
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
loadFunnelDefinition,
|
||||
} from "@/lib/funnel/loadFunnelDefinition";
|
||||
import { FunnelRuntime } from "@/components/funnel/FunnelRuntime";
|
||||
import { FunnelUnleashWrapper } from "@/components/funnel/FunnelUnleashWrapper";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
|
||||
@ -99,5 +100,9 @@ export default async function FunnelScreenPage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <FunnelRuntime funnel={funnel} initialScreenId={screenId} />;
|
||||
return (
|
||||
<FunnelUnleashWrapper funnel={funnel}>
|
||||
<FunnelRuntime funnel={funnel} initialScreenId={screenId} />
|
||||
</FunnelUnleashWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PixelsProvider } from "@/components/providers/PixelsProvider";
|
||||
import { UnleashProvider } from "@/lib/funnel/unleash";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
@ -67,11 +68,13 @@ export default async function FunnelLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<PixelsProvider
|
||||
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
||||
yandexMetrikaId={funnel.meta.yandexMetrikaId}
|
||||
>
|
||||
{children}
|
||||
</PixelsProvider>
|
||||
<UnleashProvider>
|
||||
<PixelsProvider
|
||||
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
||||
yandexMetrikaId={funnel.meta.yandexMetrikaId}
|
||||
>
|
||||
{children}
|
||||
</PixelsProvider>
|
||||
</UnleashProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
|
||||
import { NavigationPanel } from "./NavigationPanel";
|
||||
import {
|
||||
useBuilderDispatch,
|
||||
useBuilderSelectedScreen,
|
||||
@ -13,7 +14,6 @@ import {
|
||||
} from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type {
|
||||
NavigationRuleDefinition,
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
@ -125,171 +125,6 @@ export function BuilderSidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const getScreenById = (screenId: string): BuilderScreen | undefined =>
|
||||
state.screens.find((item) => item.id === screenId);
|
||||
|
||||
const updateNavigation = (
|
||||
screen: BuilderScreen,
|
||||
navigationUpdates: Partial<BuilderScreen["navigation"]> = {}
|
||||
) => {
|
||||
dispatch({
|
||||
type: "update-navigation",
|
||||
payload: {
|
||||
screenId: screen.id,
|
||||
navigation: {
|
||||
defaultNextScreenId:
|
||||
navigationUpdates.defaultNextScreenId ??
|
||||
screen.navigation?.defaultNextScreenId,
|
||||
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
|
||||
isEndScreen:
|
||||
navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
|
||||
onBackScreenId:
|
||||
navigationUpdates.onBackScreenId ?? screen.navigation?.onBackScreenId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDefaultNextChange = (
|
||||
screenId: string,
|
||||
nextScreenId: string | ""
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNavigation(screen, {
|
||||
defaultNextScreenId: nextScreenId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const updateRules = (screenId: string, rules: NavigationRuleDefinition[]) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNavigation(screen, { rules });
|
||||
};
|
||||
|
||||
const handleRuleOperatorChange = (
|
||||
screenId: string,
|
||||
index: number,
|
||||
operator: NavigationRuleDefinition["conditions"][0]["operator"]
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, ruleIndex) =>
|
||||
ruleIndex === index
|
||||
? {
|
||||
...rule,
|
||||
conditions: rule.conditions.map((condition, conditionIndex) =>
|
||||
conditionIndex === 0
|
||||
? {
|
||||
...condition,
|
||||
operator,
|
||||
}
|
||||
: condition
|
||||
),
|
||||
}
|
||||
: rule
|
||||
);
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleRuleOptionToggle = (
|
||||
screenId: string,
|
||||
ruleIndex: number,
|
||||
optionId: string
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, currentIndex) => {
|
||||
if (currentIndex !== ruleIndex) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const [condition] = rule.conditions;
|
||||
const optionIds = new Set(condition.optionIds ?? []);
|
||||
if (optionIds.has(optionId)) {
|
||||
optionIds.delete(optionId);
|
||||
} else {
|
||||
optionIds.add(optionId);
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
conditions: [
|
||||
{
|
||||
...condition,
|
||||
optionIds: Array.from(optionIds),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleRuleNextScreenChange = (
|
||||
screenId: string,
|
||||
ruleIndex: number,
|
||||
nextScreenId: string
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, currentIndex) =>
|
||||
currentIndex === ruleIndex ? { ...rule, nextScreenId } : rule
|
||||
);
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleAddRule = (screen: BuilderScreen) => {
|
||||
if (!isListScreen(screen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultCondition: NavigationRuleDefinition["conditions"][number] = {
|
||||
screenId: screen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: screen.list.options.slice(0, 1).map((option) => option.id),
|
||||
};
|
||||
|
||||
const nextRules = [
|
||||
...(screen.navigation?.rules ?? []),
|
||||
{
|
||||
nextScreenId: state.screens[0]?.id ?? screen.id,
|
||||
conditions: [defaultCondition],
|
||||
},
|
||||
];
|
||||
updateNavigation(screen, { rules: nextRules });
|
||||
};
|
||||
|
||||
const handleRemoveRule = (screenId: string, ruleIndex: number) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.filter((_, index) => index !== ruleIndex);
|
||||
updateNavigation(screen, { rules: nextRules });
|
||||
};
|
||||
|
||||
const handleDeleteScreen = (screenId: string) => {
|
||||
if (state.screens.length <= 1) {
|
||||
@ -326,10 +161,6 @@ export function BuilderSidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
const selectedScreenIsListType = selectedScreen
|
||||
? isListScreen(selectedScreen)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="sticky top-0 z-20 border-b border-border/60 bg-background/95 px-4 py-3">
|
||||
@ -579,235 +410,8 @@ export function BuilderSidebar() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Навигация">
|
||||
{/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScreen.navigation?.isEndScreen ?? false}
|
||||
onChange={(e) => {
|
||||
updateNavigation(selectedScreen, {
|
||||
isEndScreen: e.target.checked,
|
||||
});
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Финальный экран
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Этот экран завершает воронку (переход не требуется)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
|
||||
{!selectedScreen.navigation?.isEndScreen && (
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Экран по умолчанию
|
||||
</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
||||
onChange={(event) =>
|
||||
handleDefaultNextChange(
|
||||
selectedScreen.id,
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Экран при попытке перехода назад */}
|
||||
<label className="flex flex-col gap-2 mt-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Экран при попытке перехода назад
|
||||
</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={selectedScreen.navigation?.onBackScreenId ?? ""}
|
||||
onChange={(e) =>
|
||||
updateNavigation(selectedScreen, {
|
||||
...(selectedScreen.navigation ?? {}),
|
||||
onBackScreenId: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{screenOptions
|
||||
.filter((s) => s.id !== selectedScreen.id)
|
||||
.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
{selectedScreenIsListType &&
|
||||
!selectedScreen.navigation?.isEndScreen && (
|
||||
<Section
|
||||
title="Правила переходов"
|
||||
description="Условная навигация"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Направляйте пользователей на разные экраны в зависимости
|
||||
от выбора.
|
||||
</p>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 flex items-center justify-center"
|
||||
onClick={() => handleAddRule(selectedScreen)}
|
||||
>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
|
||||
Правил пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedScreen.navigation?.rules ?? []).map(
|
||||
(rule, ruleIndex) => (
|
||||
<div
|
||||
key={ruleIndex}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Правило {ruleIndex + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
handleRemoveRule(selectedScreen.id, ruleIndex)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Оператор
|
||||
</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={
|
||||
rule.conditions[0]?.operator ?? "includesAny"
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleRuleOperatorChange(
|
||||
selectedScreen.id,
|
||||
ruleIndex,
|
||||
event.target
|
||||
.value as NavigationRuleDefinition["conditions"][0]["operator"]
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="includesAny">contains any</option>
|
||||
<option value="includesAll">contains all</option>
|
||||
<option value="includesExactly">
|
||||
exact match
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedScreen.template === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Варианты ответа
|
||||
</span>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||
{selectedScreen.list.options.map((option) => {
|
||||
const condition = rule.conditions[0];
|
||||
const isChecked =
|
||||
condition.optionIds?.includes(option.id) ??
|
||||
false;
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() =>
|
||||
handleRuleOptionToggle(
|
||||
selectedScreen.id,
|
||||
ruleIndex,
|
||||
option.id
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({option.id})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
Навигационные правила с вариантами ответа доступны
|
||||
только для экранов со списком.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Следующий экран
|
||||
</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={rule.nextScreenId}
|
||||
onChange={(event) =>
|
||||
handleRuleNextScreenChange(
|
||||
selectedScreen.id,
|
||||
ruleIndex,
|
||||
event.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{screenOptions
|
||||
.filter(
|
||||
(screen) => screen.id !== selectedScreen.id
|
||||
)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{/* Используем новый NavigationPanel компонент */}
|
||||
<NavigationPanel screen={selectedScreen} />
|
||||
|
||||
<Section title="Управление">
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
useBuilderDispatch,
|
||||
useBuilderState,
|
||||
} from "@/lib/admin/builder/context";
|
||||
import { Section } from "./Section";
|
||||
import { NavigationRuleEditor } from "./NavigationRuleEditor";
|
||||
import { NavigationRulesHelper } from "./NavigationRulesHelper";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
||||
|
||||
@ -13,12 +14,6 @@ interface NavigationPanelProps {
|
||||
screen: BuilderScreen;
|
||||
}
|
||||
|
||||
function isListScreen(
|
||||
screen: BuilderScreen
|
||||
): screen is BuilderScreen & { template: "list" } {
|
||||
return screen.template === "list";
|
||||
}
|
||||
|
||||
export function NavigationPanel({ screen }: NavigationPanelProps) {
|
||||
const state = useBuilderState();
|
||||
const dispatch = useBuilderDispatch();
|
||||
@ -28,8 +23,6 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
|
||||
[state.screens]
|
||||
);
|
||||
|
||||
const selectedScreenIsListType = isListScreen(screen);
|
||||
|
||||
const getScreenById = (screenId: string): BuilderScreen | undefined =>
|
||||
state.screens.find((item) => item.id === screenId);
|
||||
|
||||
@ -82,16 +75,28 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
|
||||
const firstScreenOption = screenOptions.find(
|
||||
(s) => s.id !== targetScreen.id
|
||||
);
|
||||
|
||||
// Создаем базовое условие в зависимости от типа экрана
|
||||
const isListType = targetScreen.template === "list";
|
||||
const baseCondition = isListType
|
||||
? {
|
||||
screenId: targetScreen.id,
|
||||
conditionType: "options" as const,
|
||||
operator: "includesAny" as const,
|
||||
optionIds: [],
|
||||
}
|
||||
: {
|
||||
screenId: targetScreen.id,
|
||||
conditionType: "unleash" as const,
|
||||
operator: "includesAny" as const,
|
||||
unleashFlag: "",
|
||||
unleashVariants: [],
|
||||
};
|
||||
|
||||
updateRules(targetScreen.id, [
|
||||
...rules,
|
||||
{
|
||||
conditions: [
|
||||
{
|
||||
screenId: targetScreen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: [],
|
||||
},
|
||||
],
|
||||
conditions: [baseCondition],
|
||||
nextScreenId: firstScreenOption?.id || "",
|
||||
},
|
||||
]);
|
||||
@ -185,13 +190,15 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
|
||||
{/* )} */}
|
||||
</Section>
|
||||
|
||||
{selectedScreenIsListType && !screen.navigation?.isEndScreen && (
|
||||
{!screen.navigation?.isEndScreen && (
|
||||
<Section title="Правила переходов" description="Условная навигация">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Подсказка о возможностях */}
|
||||
<NavigationRulesHelper />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Направляйте пользователей на разные экраны в зависимости от
|
||||
выбора.
|
||||
Направляйте пользователей на разные экраны в зависимости от условий.
|
||||
</p>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 flex items-center justify-center"
|
||||
@ -208,32 +215,21 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
|
||||
)}
|
||||
|
||||
{(screen.navigation?.rules ?? []).map((rule, ruleIndex) => (
|
||||
<div
|
||||
<NavigationRuleEditor
|
||||
key={ruleIndex}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Правило {ruleIndex + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveRule(screen.id, ruleIndex)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{/* Здесь должна быть полная логика редактирования правил */}
|
||||
{/* Для краткости оставляем только структуру */}
|
||||
<p>
|
||||
Правило №{ruleIndex + 1} - редактирование правил сохранено в
|
||||
оригинальном компоненте
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
rule={rule}
|
||||
ruleIndex={ruleIndex}
|
||||
screen={screen}
|
||||
allScreens={state.screens}
|
||||
screenOptions={screenOptions}
|
||||
onRuleChange={(updatedRule) => {
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const updatedRules = [...rules];
|
||||
updatedRules[ruleIndex] = updatedRule;
|
||||
updateRules(screen.id, updatedRules);
|
||||
}}
|
||||
onRuleDelete={() => handleRemoveRule(screen.id, ruleIndex)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
147
src/components/admin/builder/Sidebar/NavigationRuleEditor.tsx
Normal file
147
src/components/admin/builder/Sidebar/NavigationRuleEditor.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { VariantConditionEditor } from "../forms/variants/VariantConditionEditor";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface NavigationRuleEditorProps {
|
||||
rule: NavigationRuleDefinition;
|
||||
ruleIndex: number;
|
||||
screen: BuilderScreen;
|
||||
allScreens: BuilderScreen[];
|
||||
screenOptions: Array<{ id: string; title?: string }>;
|
||||
onRuleChange: (updatedRule: NavigationRuleDefinition) => void;
|
||||
onRuleDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактор одного правила навигации
|
||||
*/
|
||||
export function NavigationRuleEditor({
|
||||
rule,
|
||||
ruleIndex,
|
||||
screen,
|
||||
allScreens,
|
||||
screenOptions,
|
||||
onRuleChange,
|
||||
onRuleDelete,
|
||||
}: NavigationRuleEditorProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3">
|
||||
{/* Заголовок правила */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Правило {ruleIndex + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={onRuleDelete}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Условия */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-foreground">
|
||||
Условия (все должны выполняться)
|
||||
</label>
|
||||
|
||||
{rule.conditions.map((condition, conditionIndex) => (
|
||||
<div key={conditionIndex} className="relative">
|
||||
<VariantConditionEditor
|
||||
condition={condition}
|
||||
allScreens={allScreens}
|
||||
onChange={(updatedCondition) => {
|
||||
const updatedConditions = [...rule.conditions];
|
||||
updatedConditions[conditionIndex] = updatedCondition;
|
||||
onRuleChange({
|
||||
...rule,
|
||||
conditions: updatedConditions,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{rule.conditions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-0 right-0 h-6 px-2 text-destructive"
|
||||
onClick={() => {
|
||||
const updatedConditions = rule.conditions.filter(
|
||||
(_, i) => i !== conditionIndex
|
||||
);
|
||||
onRuleChange({
|
||||
...rule,
|
||||
conditions: updatedConditions,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Кнопка добавления условия */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-8 text-xs"
|
||||
onClick={() => {
|
||||
const isListType = screen.template === "list";
|
||||
const newCondition = isListType
|
||||
? {
|
||||
screenId: screen.id,
|
||||
conditionType: "options" as const,
|
||||
operator: "includesAny" as const,
|
||||
optionIds: [],
|
||||
}
|
||||
: {
|
||||
screenId: screen.id,
|
||||
conditionType: "unleash" as const,
|
||||
operator: "includesAny" as const,
|
||||
unleashFlag: "",
|
||||
unleashVariants: [],
|
||||
};
|
||||
|
||||
onRuleChange({
|
||||
...rule,
|
||||
conditions: [...rule.conditions, newCondition],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Добавить условие
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Целевой экран */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-foreground">
|
||||
Переход на экран
|
||||
</label>
|
||||
<select
|
||||
value={rule.nextScreenId}
|
||||
onChange={(e) =>
|
||||
onRuleChange({
|
||||
...rule,
|
||||
nextScreenId: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Выберите экран</option>
|
||||
{screenOptions
|
||||
.filter((s) => s.id !== screen.id)
|
||||
.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || s.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Подсказка о возможностях правил навигации
|
||||
*/
|
||||
export function NavigationRulesHelper() {
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900 p-3 text-xs">
|
||||
<div className="flex gap-2">
|
||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-blue-900 dark:text-blue-100">
|
||||
Правила навигации поддерживают все типы условий:
|
||||
</p>
|
||||
<ul className="space-y-1 text-blue-800 dark:text-blue-200">
|
||||
<li>• <strong>AB тесты (Unleash)</strong> - тестируйте разные пути через воронку</li>
|
||||
<li>• <strong>Опции</strong> - на основе выбора в list экранах</li>
|
||||
<li>• <strong>Значения</strong> - возраст, зодиак, email домен</li>
|
||||
<li>• <strong>Комбинации</strong> - несколько условий одновременно (AND)</li>
|
||||
</ul>
|
||||
<p className="text-blue-700 dark:text-blue-300 mt-2">
|
||||
Для AB теста выберите <strong>"Тип условия" → "AB тест (Unleash)"</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/admin/builder/forms/ConditionTypeSelector.tsx
Normal file
37
src/components/admin/builder/forms/ConditionTypeSelector.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface ConditionTypeSelectorProps {
|
||||
condition: NavigationConditionDefinition;
|
||||
onChange: (conditionType: "options" | "values" | "unleash") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Селектор типа условия: options / values / unleash
|
||||
*/
|
||||
export function ConditionTypeSelector({
|
||||
condition,
|
||||
onChange,
|
||||
}: ConditionTypeSelectorProps) {
|
||||
const conditionType = condition.conditionType ?? "options";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Тип условия
|
||||
</label>
|
||||
<select
|
||||
value={conditionType}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value as "options" | "values" | "unleash")
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="options">Выбранные опции (list экраны)</option>
|
||||
<option value="values">Значения (зодиак, возраст, email)</option>
|
||||
<option value="unleash">AB тест (Unleash)</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/admin/builder/forms/UnleashConditionEditor.tsx
Normal file
120
src/components/admin/builder/forms/UnleashConditionEditor.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface UnleashConditionEditorProps {
|
||||
condition: NavigationConditionDefinition;
|
||||
onChange: (condition: NavigationConditionDefinition) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактор для Unleash AB тестов
|
||||
* Позволяет настроить флаг и ожидаемые варианты
|
||||
*/
|
||||
export function UnleashConditionEditor({
|
||||
condition,
|
||||
onChange,
|
||||
}: UnleashConditionEditorProps) {
|
||||
const unleashFlag = condition.unleashFlag ?? "";
|
||||
const unleashVariants = condition.unleashVariants ?? [];
|
||||
const operator = condition.operator ?? "includesAny";
|
||||
|
||||
const handleFlagChange = (newFlag: string) => {
|
||||
onChange({
|
||||
...condition,
|
||||
unleashFlag: newFlag,
|
||||
});
|
||||
};
|
||||
|
||||
const handleVariantsChange = (variantsStr: string) => {
|
||||
// Парсим строку вариантов: "v1,v2,v3" -> ["v1", "v2", "v3"]
|
||||
const variants = variantsStr
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
|
||||
onChange({
|
||||
...condition,
|
||||
unleashVariants: variants,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (
|
||||
newOperator: "includesAny" | "includesAll" | "includesExactly" | "equals"
|
||||
) => {
|
||||
onChange({
|
||||
...condition,
|
||||
operator: newOperator,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Название флага */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Название флага
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={unleashFlag}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleFlagChange(e.target.value)}
|
||||
placeholder="trial-button-test"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Название Unleash feature flag
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Оператор */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Условие проверки
|
||||
</label>
|
||||
<select
|
||||
value={operator}
|
||||
onChange={(e) =>
|
||||
handleOperatorChange(
|
||||
e.target.value as
|
||||
| "includesAny"
|
||||
| "includesAll"
|
||||
| "includesExactly"
|
||||
| "equals"
|
||||
)
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="includesAny">Любой из вариантов (OR)</option>
|
||||
<option value="includesAll">Все варианты (AND)</option>
|
||||
<option value="includesExactly">Точно эти варианты</option>
|
||||
<option value="equals">Равен (один вариант)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Варианты */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Ожидаемые варианты
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={unleashVariants.join(", ")}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleVariantsChange(e.target.value)}
|
||||
placeholder="v1, v2, v3"
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Через запятую: v0, v1, v2
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Подсказка */}
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-xs text-blue-900">
|
||||
<strong>Пример:</strong> Флаг "trial-button-test" с вариантами "v1, v2"
|
||||
будет активен если пользователь в группе v1 или v2
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,8 @@ import { useMemo } from "react";
|
||||
import { AgeSelector } from "../AgeSelector";
|
||||
import { ZodiacSelector } from "../ZodiacSelector";
|
||||
import { EmailDomainSelector } from "../EmailDomainSelector";
|
||||
import { ConditionTypeSelector } from "../ConditionTypeSelector";
|
||||
import { UnleashConditionEditor } from "../UnleashConditionEditor";
|
||||
import type { VariantConditionEditorProps } from "./types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { ListOptionDefinition } from "@/lib/funnel/types";
|
||||
@ -98,6 +100,9 @@ export function VariantConditionEditor({
|
||||
const showAgeSelector =
|
||||
selectedScreen?.id === "age" || selectedScreen?.id === "crush-age" || selectedScreen?.id === "current-partner-age";
|
||||
|
||||
const conditionType = condition.conditionType ?? "options";
|
||||
const isUnleashCondition = conditionType === "unleash";
|
||||
|
||||
// Обработчики для селекторов
|
||||
const handleToggleOption = (optionId: string) => {
|
||||
const currentIds = condition.optionIds || [];
|
||||
@ -114,13 +119,36 @@ export function VariantConditionEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const handleConditionTypeChange = (newType: "options" | "values" | "unleash") => {
|
||||
onChange({
|
||||
...condition,
|
||||
conditionType: newType,
|
||||
// Очищаем поля при смене типа
|
||||
optionIds: newType === "unleash" ? undefined : condition.optionIds,
|
||||
values: newType === "unleash" ? undefined : condition.values,
|
||||
unleashFlag: newType === "unleash" ? condition.unleashFlag : undefined,
|
||||
unleashVariants: newType === "unleash" ? condition.unleashVariants : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Выбор экрана */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Экран
|
||||
</label>
|
||||
{/* Тип условия */}
|
||||
<ConditionTypeSelector
|
||||
condition={condition}
|
||||
onChange={handleConditionTypeChange}
|
||||
/>
|
||||
|
||||
{/* Unleash AB Test */}
|
||||
{isUnleashCondition ? (
|
||||
<UnleashConditionEditor condition={condition} onChange={onChange} />
|
||||
) : (
|
||||
<>
|
||||
{/* Выбор экрана */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Экран
|
||||
</label>
|
||||
<select
|
||||
value={condition.screenId}
|
||||
onChange={(e) => onChange({ ...condition, screenId: e.target.value, optionIds: [] })}
|
||||
@ -249,6 +277,8 @@ export function VariantConditionEditor({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
22
src/components/funnel/FunnelLoadingScreen.tsx
Normal file
22
src/components/funnel/FunnelLoadingScreen.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Экран загрузки воронки
|
||||
* Показывается пока загружаются AB тесты и воронка инициализируется
|
||||
*/
|
||||
export function FunnelLoadingScreen() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Spinner */}
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-primary/20"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
|
||||
{/* Текст */}
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { resolveNextScreenId } from "@/lib/funnel/navigation";
|
||||
import { resolveNextScreenId, type UnleashChecker } from "@/lib/funnel/navigation";
|
||||
import { resolveScreenVariant } from "@/lib/funnel/variants";
|
||||
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
|
||||
import { renderScreen } from "@/lib/funnel/screenRenderer";
|
||||
@ -16,11 +16,13 @@ import type {
|
||||
import { getZodiacSign } from "@/lib/funnel/zodiac";
|
||||
import { useSession } from "@/hooks/session/useSession";
|
||||
import { buildSessionDataFromScreen } from "@/lib/funnel/registrationHelpers";
|
||||
import { useUnleashContext, sendUnleashImpression } from "@/lib/funnel/unleash";
|
||||
|
||||
// Функция для оценки длины пути пользователя на основе текущих ответов
|
||||
function estimatePathLength(
|
||||
funnel: FunnelDefinition,
|
||||
answers: FunnelAnswers
|
||||
answers: FunnelAnswers,
|
||||
unleashChecker?: UnleashChecker
|
||||
): number {
|
||||
const visited = new Set<string>();
|
||||
let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id;
|
||||
@ -35,12 +37,14 @@ function estimatePathLength(
|
||||
const resolvedScreen = resolveScreenVariant(
|
||||
currentScreen,
|
||||
answers,
|
||||
funnel.screens
|
||||
funnel.screens,
|
||||
unleashChecker
|
||||
);
|
||||
const nextScreenId = resolveNextScreenId(
|
||||
resolvedScreen,
|
||||
answers,
|
||||
funnel.screens
|
||||
funnel.screens,
|
||||
unleashChecker
|
||||
);
|
||||
|
||||
// Если достигли конца или зацикливание
|
||||
@ -67,6 +71,15 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
const { answers, registerScreen, setAnswers, history } = useFunnelRuntime(
|
||||
funnel.meta.id
|
||||
);
|
||||
const { checkVariant, activeVariants } = useUnleashContext();
|
||||
|
||||
// Создаем unleashChecker функцию для передачи в navigation/variants
|
||||
const unleashChecker: UnleashChecker = useCallback(
|
||||
(flag, expectedVariants, operator) => {
|
||||
return checkVariant(flag, expectedVariants, operator);
|
||||
},
|
||||
[checkVariant]
|
||||
);
|
||||
|
||||
// ✅ Screen Map для O(1) поиска вместо O(n)
|
||||
const screenMap = useMemo(() => {
|
||||
@ -82,11 +95,36 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
}, [screenMap, initialScreenId, funnel.screens]);
|
||||
|
||||
const currentScreen = useMemo(() => {
|
||||
return resolveScreenVariant(baseScreen, answers, funnel.screens);
|
||||
}, [baseScreen, answers, funnel.screens]);
|
||||
return resolveScreenVariant(baseScreen, answers, funnel.screens, unleashChecker);
|
||||
}, [baseScreen, answers, funnel.screens, unleashChecker]);
|
||||
|
||||
const selectedOptionIds = answers[currentScreen.id] ?? [];
|
||||
|
||||
// Собираем флаги которые используются на текущем экране
|
||||
const currentScreenFlags = useMemo(() => {
|
||||
const flags = new Set<string>();
|
||||
|
||||
// Флаги из вариантов текущего экрана
|
||||
currentScreen.variants?.forEach((variant) => {
|
||||
variant.conditions.forEach((condition) => {
|
||||
if (condition.conditionType === "unleash" && condition.unleashFlag) {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Флаги из правил навигации текущего экрана
|
||||
currentScreen.navigation?.rules?.forEach((rule) => {
|
||||
rule.conditions.forEach((condition) => {
|
||||
if (condition.conditionType === "unleash" && condition.unleashFlag) {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(flags);
|
||||
}, [currentScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
createSession();
|
||||
}, [createSession]);
|
||||
@ -95,6 +133,22 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
registerScreen(currentScreen.id);
|
||||
}, [currentScreen.id, registerScreen]);
|
||||
|
||||
// Отправляем impression события в GA когда пользователь видит экран с AB тестами
|
||||
useEffect(() => {
|
||||
if (currentScreenFlags.length === 0) {
|
||||
return; // Нет AB тестов на этом экране
|
||||
}
|
||||
|
||||
// Отправляем impression для каждого флага на этом экране
|
||||
currentScreenFlags.forEach((flag) => {
|
||||
// Получаем вариант для флага из контекста (он уже загружен через FunnelUnleashWrapper)
|
||||
const variant = activeVariants[flag];
|
||||
|
||||
// Отправляем событие (внутри есть защита от дубликатов через sessionStorage)
|
||||
sendUnleashImpression(flag, variant);
|
||||
});
|
||||
}, [currentScreenFlags, activeVariants]);
|
||||
|
||||
const historyWithCurrent = useMemo(() => {
|
||||
if (history.length === 0) {
|
||||
return [currentScreen.id];
|
||||
@ -115,10 +169,10 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
|
||||
// Calculate automatic progress based on user's actual path
|
||||
const screenProgress = useMemo(() => {
|
||||
const total = estimatePathLength(funnel, answers);
|
||||
const total = estimatePathLength(funnel, answers, unleashChecker);
|
||||
const current = historyWithCurrent.length; // Номер текущего экрана = количество посещенных
|
||||
return { current, total };
|
||||
}, [historyWithCurrent.length, funnel, answers]);
|
||||
}, [historyWithCurrent.length, funnel, answers, unleashChecker]);
|
||||
|
||||
const goToScreen = (screenId: string | undefined) => {
|
||||
if (!screenId) {
|
||||
@ -156,7 +210,8 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
const nextScreenId = resolveNextScreenId(
|
||||
currentScreen,
|
||||
answers,
|
||||
funnel.screens
|
||||
funnel.screens,
|
||||
unleashChecker
|
||||
);
|
||||
goToScreen(nextScreenId);
|
||||
};
|
||||
@ -250,7 +305,8 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
const nextScreenId = resolveNextScreenId(
|
||||
currentScreen,
|
||||
nextAnswers,
|
||||
funnel.screens
|
||||
funnel.screens,
|
||||
unleashChecker
|
||||
);
|
||||
goToScreen(nextScreenId);
|
||||
}
|
||||
|
||||
106
src/components/funnel/FunnelUnleashWrapper.tsx
Normal file
106
src/components/funnel/FunnelUnleashWrapper.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useFlagsStatus, useVariant } from "@unleash/proxy-client-react";
|
||||
import { UnleashContextProvider } from "@/lib/funnel/unleash";
|
||||
import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
|
||||
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface FunnelUnleashWrapperProps {
|
||||
children: ReactNode;
|
||||
funnel: {
|
||||
screens: Array<{
|
||||
id: string;
|
||||
variants?: Array<{
|
||||
conditions: NavigationConditionDefinition[];
|
||||
}>;
|
||||
navigation?: {
|
||||
rules?: Array<{
|
||||
conditions: NavigationConditionDefinition[];
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper который собирает все Unleash флаги используемые в воронке
|
||||
* и передает их активные варианты в контекст
|
||||
*/
|
||||
export function FunnelUnleashWrapper({
|
||||
children,
|
||||
funnel,
|
||||
}: FunnelUnleashWrapperProps) {
|
||||
const { flagsReady } = useFlagsStatus();
|
||||
|
||||
// Собираем все уникальные флаги из воронки
|
||||
const allFlags = useMemo(() => {
|
||||
const flags = new Set<string>();
|
||||
|
||||
funnel.screens.forEach((screen) => {
|
||||
// Флаги из вариантов экрана
|
||||
screen.variants?.forEach((variant) => {
|
||||
variant.conditions.forEach((condition) => {
|
||||
if (
|
||||
condition.conditionType === "unleash" &&
|
||||
condition.unleashFlag
|
||||
) {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Флаги из правил навигации
|
||||
screen.navigation?.rules?.forEach((rule) => {
|
||||
rule.conditions.forEach((condition) => {
|
||||
if (
|
||||
condition.conditionType === "unleash" &&
|
||||
condition.unleashFlag
|
||||
) {
|
||||
flags.add(condition.unleashFlag);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(flags);
|
||||
}, [funnel.screens]);
|
||||
|
||||
// Получаем варианты для всех флагов
|
||||
const flagVariants = allFlags.map((flag) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const variant = useVariant(flag);
|
||||
return {
|
||||
flag,
|
||||
variant: variant?.name,
|
||||
};
|
||||
});
|
||||
|
||||
// Создаем объект активных вариантов
|
||||
const activeVariants = useMemo(() => {
|
||||
if (!flagsReady) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const variants: Record<string, string> = {};
|
||||
flagVariants.forEach(({ flag, variant }) => {
|
||||
if (variant && variant !== "disabled") {
|
||||
variants[flag] = variant;
|
||||
}
|
||||
});
|
||||
|
||||
return variants;
|
||||
}, [flagsReady, flagVariants]);
|
||||
|
||||
// Показываем loader пока флаги загружаются
|
||||
// Это предотвращает flash of unstyled content
|
||||
if (!flagsReady) {
|
||||
return <FunnelLoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UnleashContextProvider activeVariants={activeVariants}>
|
||||
{children}
|
||||
</UnleashContextProvider>
|
||||
);
|
||||
}
|
||||
@ -131,7 +131,40 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
||||
"showPrivacyTermsConsent": false
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [],
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"operator": "includesAny",
|
||||
"optionIds": [],
|
||||
"values": [],
|
||||
"unleashFlag": "soulmate-gender-info",
|
||||
"unleashVariants": [
|
||||
"v0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "partner-gender"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"conditionType": "unleash",
|
||||
"operator": "includesAny",
|
||||
"optionIds": [],
|
||||
"values": [],
|
||||
"unleashFlag": "soulmate-gender-info",
|
||||
"unleashVariants": [
|
||||
"v1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "gender-info"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-gender",
|
||||
"isEndScreen": false
|
||||
},
|
||||
@ -153,7 +186,81 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
||||
],
|
||||
"registrationFieldKey": "profile.gender"
|
||||
},
|
||||
"variants": []
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "onboarding",
|
||||
"operator": "includesAny",
|
||||
"conditionType": "unleash",
|
||||
"unleashFlag": "soulmate-onboarding-image",
|
||||
"unleashVariants": [
|
||||
"v2"
|
||||
]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "What’s your gender? v2"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gender-info",
|
||||
"template": "info",
|
||||
"header": {
|
||||
"showBackButton": true,
|
||||
"show": true
|
||||
},
|
||||
"title": {
|
||||
"text": "Over 120,000 women have already taken this step to get the most accurate results.",
|
||||
"show": true,
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"size": "2xl",
|
||||
"align": "center",
|
||||
"color": "default"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "…and gained a deep portrait of their partner.",
|
||||
"show": true,
|
||||
"font": "manrope",
|
||||
"weight": "medium",
|
||||
"size": "lg",
|
||||
"align": "center",
|
||||
"color": "default"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"show": true,
|
||||
"cornerRadius": "3xl",
|
||||
"showPrivacyTermsConsent": false
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [],
|
||||
"defaultNextScreenId": "partner-gender",
|
||||
"isEndScreen": false
|
||||
},
|
||||
"variables": [],
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "gender",
|
||||
"operator": "includesAny",
|
||||
"optionIds": [
|
||||
"male"
|
||||
]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Over 80,000 men have already taken this step to get the most accurate results."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "partner-gender",
|
||||
@ -355,7 +462,8 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
||||
"optionIds": [
|
||||
"under_29"
|
||||
],
|
||||
"values": []
|
||||
"values": [],
|
||||
"unleashVariants": []
|
||||
}
|
||||
],
|
||||
"nextScreenId": "partner-age-detail"
|
||||
|
||||
@ -2,6 +2,16 @@ import { FunnelAnswers, NavigationConditionDefinition, NavigationRuleDefinition,
|
||||
import { calculateAgeFromArray, createAgeValue, createGenerationValue } from "@/lib/age-utils";
|
||||
import { getZodiacSign } from "@/lib/funnel/zodiac";
|
||||
|
||||
/**
|
||||
* Тип для функции проверки Unleash условий
|
||||
* Передается извне чтобы избежать зависимости от React hooks
|
||||
*/
|
||||
export type UnleashChecker = (
|
||||
flag: string,
|
||||
expectedVariants: string[],
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly" | "equals"
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* Расширенная функция получения ответов экрана
|
||||
* Автоматически рассчитывает возраст и знак зодиака для date экранов
|
||||
@ -50,11 +60,28 @@ function getScreenAnswers(answers: FunnelAnswers, screenId: string, allScreens?:
|
||||
function satisfiesCondition(
|
||||
condition: NavigationConditionDefinition,
|
||||
answers: FunnelAnswers,
|
||||
allScreens?: ScreenDefinition[]
|
||||
allScreens?: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker
|
||||
): boolean {
|
||||
const conditionType = condition.conditionType ?? "options";
|
||||
|
||||
// 🎯 UNLEASH AB ТЕСТЫ: проверка через Unleash feature flags
|
||||
if (conditionType === "unleash") {
|
||||
if (!unleashChecker || !condition.unleashFlag || !condition.unleashVariants) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const operator = condition.operator ?? "includesAny";
|
||||
return unleashChecker(
|
||||
condition.unleashFlag,
|
||||
condition.unleashVariants,
|
||||
operator
|
||||
);
|
||||
}
|
||||
|
||||
// Существующая логика для options и values
|
||||
const selected = new Set(getScreenAnswers(answers, condition.screenId, allScreens));
|
||||
const operator = condition.operator ?? "includesAny";
|
||||
const conditionType = condition.conditionType ?? "options";
|
||||
|
||||
// 🎯 НОВАЯ ЛОГИКА: поддержка values для любых экранов
|
||||
const expectedValues = conditionType === "values"
|
||||
@ -102,29 +129,36 @@ function satisfiesCondition(
|
||||
export function matchesNavigationConditions(
|
||||
conditions: NavigationConditionDefinition[] | undefined,
|
||||
answers: FunnelAnswers,
|
||||
allScreens?: ScreenDefinition[]
|
||||
allScreens?: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker
|
||||
): boolean {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return conditions.every((condition) => satisfiesCondition(condition, answers, allScreens));
|
||||
return conditions.every((condition) => satisfiesCondition(condition, answers, allScreens, unleashChecker));
|
||||
}
|
||||
|
||||
function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers, allScreens?: ScreenDefinition[]): boolean {
|
||||
return matchesNavigationConditions(rule.conditions, answers, allScreens);
|
||||
function satisfiesRule(
|
||||
rule: NavigationRuleDefinition,
|
||||
answers: FunnelAnswers,
|
||||
allScreens?: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker
|
||||
): boolean {
|
||||
return matchesNavigationConditions(rule.conditions, answers, allScreens, unleashChecker);
|
||||
}
|
||||
|
||||
export function resolveNextScreenId(
|
||||
currentScreen: ScreenDefinition,
|
||||
answers: FunnelAnswers,
|
||||
orderedScreens: ScreenDefinition[]
|
||||
orderedScreens: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker
|
||||
): string | undefined {
|
||||
const navigation = currentScreen.navigation;
|
||||
|
||||
if (navigation?.rules) {
|
||||
for (const rule of navigation.rules) {
|
||||
if (satisfiesRule(rule, answers, orderedScreens)) {
|
||||
if (satisfiesRule(rule, answers, orderedScreens, unleashChecker)) {
|
||||
return rule.nextScreenId;
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,8 +80,9 @@ export interface NavigationConditionDefinition {
|
||||
* Тип условия:
|
||||
* - options: проверка выбранных опций в списках
|
||||
* - values: проверка конкретных значений (зодиак, email, дата, etc.)
|
||||
* - unleash: проверка AB тестов через Unleash feature flags
|
||||
*/
|
||||
conditionType?: "options" | "values";
|
||||
conditionType?: "options" | "values" | "unleash";
|
||||
/**
|
||||
* - includesAny: at least one option/value is present.
|
||||
* - includesAll: all of the options/values are present.
|
||||
@ -95,6 +96,19 @@ export interface NavigationConditionDefinition {
|
||||
|
||||
// Для любых экранов - универсальные значения
|
||||
values?: string[];
|
||||
|
||||
// Для Unleash AB тестов
|
||||
/**
|
||||
* Название Unleash feature flag (например, "trial-button-test")
|
||||
* Используется только когда conditionType === "unleash"
|
||||
*/
|
||||
unleashFlag?: string;
|
||||
/**
|
||||
* Ожидаемые варианты флага (например, ["v1", "v2"])
|
||||
* Проверяется с помощью operator (includesAny, includesAll, equals)
|
||||
* Используется только когда conditionType === "unleash"
|
||||
*/
|
||||
unleashVariants?: string[];
|
||||
}
|
||||
|
||||
export interface NavigationRuleDefinition {
|
||||
|
||||
81
src/lib/funnel/unleash/UnleashContext.tsx
Normal file
81
src/lib/funnel/unleash/UnleashContext.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
interface UnleashContextValue {
|
||||
/**
|
||||
* Текущие активные варианты флагов
|
||||
* Ключ - название флага, значение - вариант
|
||||
*/
|
||||
activeVariants: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Проверяет соответствует ли флаг ожидаемым вариантам
|
||||
*/
|
||||
checkVariant: (
|
||||
flag: string,
|
||||
expectedVariants: string[],
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly" | "equals"
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
const UnleashContext = createContext<UnleashContextValue | null>(null);
|
||||
|
||||
interface UnleashContextProviderProps {
|
||||
children: ReactNode;
|
||||
activeVariants: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Провайдер контекста для хранения текущих вариантов Unleash
|
||||
* Используется в navigation и variants logic
|
||||
*/
|
||||
export function UnleashContextProvider({
|
||||
children,
|
||||
activeVariants,
|
||||
}: UnleashContextProviderProps) {
|
||||
const checkVariant = (
|
||||
flag: string,
|
||||
expectedVariants: string[],
|
||||
operator: "includesAny" | "includesAll" | "includesExactly" | "equals" = "includesAny"
|
||||
): boolean => {
|
||||
const currentVariant = activeVariants[flag];
|
||||
|
||||
if (!currentVariant || expectedVariants.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "includesAny":
|
||||
return expectedVariants.includes(currentVariant);
|
||||
case "includesAll":
|
||||
return expectedVariants.includes(currentVariant);
|
||||
case "includesExactly":
|
||||
return expectedVariants.length === 1 && expectedVariants[0] === currentVariant;
|
||||
case "equals":
|
||||
return expectedVariants.length === 1 && expectedVariants[0] === currentVariant;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UnleashContext.Provider value={{ activeVariants, checkVariant }}>
|
||||
{children}
|
||||
</UnleashContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUnleashContext() {
|
||||
const context = useContext(UnleashContext);
|
||||
if (!context) {
|
||||
// Возвращаем fallback если контекст не доступен
|
||||
return {
|
||||
activeVariants: {},
|
||||
checkVariant: () => false,
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { UnleashContext };
|
||||
48
src/lib/funnel/unleash/UnleashProvider.tsx
Normal file
48
src/lib/funnel/unleash/UnleashProvider.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { FlagProvider } from "@unleash/proxy-client-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface UnleashProviderProps {
|
||||
children: ReactNode;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unleash Provider для воронок
|
||||
* Инициализирует Unleash клиент с настройками для AB тестирования
|
||||
*/
|
||||
export function UnleashProvider({
|
||||
children,
|
||||
userId,
|
||||
sessionId,
|
||||
}: UnleashProviderProps) {
|
||||
// Проверяем наличие необходимых env переменных
|
||||
const unleashUrl = process.env.NEXT_PUBLIC_UNLEASH_URL;
|
||||
const unleashClientKey = process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY;
|
||||
|
||||
// Если настройки не заданы, просто возвращаем children без Unleash
|
||||
if (!unleashUrl || !unleashClientKey) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"[Unleash] Missing configuration. Set NEXT_PUBLIC_UNLEASH_URL and NEXT_PUBLIC_UNLEASH_CLIENT_KEY to enable AB testing."
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: unleashUrl,
|
||||
clientKey: unleashClientKey,
|
||||
refreshInterval: 15,
|
||||
appName: "witlab-funnel",
|
||||
context: {
|
||||
userId: userId,
|
||||
sessionId: sessionId || userId || "anonymous",
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
return <FlagProvider config={config}>{children}</FlagProvider>;
|
||||
}
|
||||
4
src/lib/funnel/unleash/index.ts
Normal file
4
src/lib/funnel/unleash/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { UnleashProvider } from "./UnleashProvider";
|
||||
export { UnleashContextProvider, useUnleashContext } from "./UnleashContext";
|
||||
export { useUnleash, checkUnleashVariant } from "./useUnleash";
|
||||
export { sendUnleashImpression, clearUnleashImpressions } from "./sendImpression";
|
||||
77
src/lib/funnel/unleash/sendImpression.ts
Normal file
77
src/lib/funnel/unleash/sendImpression.ts
Normal file
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Отправляет impression событие в Google Analytics для AB теста
|
||||
*
|
||||
* Используется в FunnelRuntime когда экран с AB тестом становится видимым
|
||||
* Автоматически предотвращает дубликаты через sessionStorage
|
||||
*
|
||||
* @param flag - название флага из Unleash
|
||||
* @param variant - вариант который получил пользователь
|
||||
*/
|
||||
export function sendUnleashImpression(flag: string, variant: string | undefined) {
|
||||
// Проверяем что вариант валидный
|
||||
if (!variant || variant === "disabled") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем что браузерное окружение
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем не отправляли ли уже это событие в этой сессии
|
||||
const storageKey = `unleash_impression_${flag}_${variant}`;
|
||||
const alreadySent = sessionStorage.getItem(storageKey);
|
||||
|
||||
if (alreadySent) {
|
||||
// Уже отправляли - пропускаем
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("[Unleash Impression] Skipped (already sent):", {
|
||||
feature: flag,
|
||||
variant: variant,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем событие в Google Analytics
|
||||
if (window.gtag) {
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: flag,
|
||||
treatment: variant,
|
||||
});
|
||||
|
||||
// Помечаем что отправили
|
||||
sessionStorage.setItem(storageKey, "true");
|
||||
|
||||
// Debug в development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("[Unleash Impression] Sent:", {
|
||||
feature: flag,
|
||||
variant: variant,
|
||||
});
|
||||
}
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
console.warn("[Unleash Impression] Google Analytics not available");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает историю отправленных impression (для тестирования)
|
||||
* В production обычно не используется
|
||||
*/
|
||||
export function clearUnleashImpressions() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(sessionStorage)
|
||||
.filter(key => key.startsWith("unleash_impression_"))
|
||||
.forEach(key => sessionStorage.removeItem(key));
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("[Unleash Impression] Cleared all impression history");
|
||||
}
|
||||
}
|
||||
70
src/lib/funnel/unleash/useScreenUnleash.ts
Normal file
70
src/lib/funnel/unleash/useScreenUnleash.ts
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useVariant } from "@unleash/proxy-client-react";
|
||||
|
||||
/**
|
||||
* Hook для загрузки AB тестов только для текущего экрана
|
||||
* Отправляет impression в GA только когда пользователь видит экран
|
||||
*
|
||||
* @param flags - массив флагов которые используются на текущем экране
|
||||
*/
|
||||
export function useScreenUnleash(flags: string[]) {
|
||||
// Получаем варианты только для флагов текущего экрана
|
||||
const variants = flags.map(flag => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const variant = useVariant(flag);
|
||||
return {
|
||||
flag,
|
||||
variant: variant?.name,
|
||||
};
|
||||
});
|
||||
|
||||
// Отправляем impression события в GA когда экран рендерится
|
||||
useEffect(() => {
|
||||
// Проверяем что GA доступен
|
||||
if (typeof window === "undefined" || !window.gtag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем события для каждого флага на этом экране
|
||||
variants.forEach(({ flag, variant }) => {
|
||||
if (variant && variant !== "disabled") {
|
||||
// Проверяем не отправляли ли уже это событие в этой сессии
|
||||
const storageKey = `unleash_impression_${flag}_${variant}`;
|
||||
const alreadySent = sessionStorage.getItem(storageKey);
|
||||
|
||||
if (!alreadySent) {
|
||||
// Отправляем в Google Analytics
|
||||
window.gtag("event", "experiment_impression", {
|
||||
app_name: "witlab-funnel",
|
||||
feature: flag,
|
||||
treatment: variant,
|
||||
});
|
||||
|
||||
// Помечаем что отправили (сохраняется до закрытия вкладки)
|
||||
sessionStorage.setItem(storageKey, "true");
|
||||
|
||||
// Debug в development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("[Screen Unleash Analytics]", {
|
||||
screen: "current",
|
||||
feature: flag,
|
||||
variant: variant,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [variants]);
|
||||
|
||||
// Возвращаем мапу активных вариантов для удобства
|
||||
const activeVariants: Record<string, string> = {};
|
||||
variants.forEach(({ flag, variant }) => {
|
||||
if (variant && variant !== "disabled") {
|
||||
activeVariants[flag] = variant;
|
||||
}
|
||||
});
|
||||
|
||||
return activeVariants;
|
||||
}
|
||||
67
src/lib/funnel/unleash/useUnleash.ts
Normal file
67
src/lib/funnel/unleash/useUnleash.ts
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useFlagsStatus, useVariant } from "@unleash/proxy-client-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface UseUnleashProps {
|
||||
flag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook для получения варианта Unleash feature flag
|
||||
* Возвращает имя варианта или undefined если флаг не активен
|
||||
*
|
||||
* ВАЖНО: Не отправляет impression автоматически!
|
||||
* Используйте sendUnleashImpression() в FunnelRuntime когда экран виден
|
||||
*/
|
||||
export function useUnleash({ flag }: UseUnleashProps) {
|
||||
const { flagsReady } = useFlagsStatus();
|
||||
const variant = useVariant(flag);
|
||||
|
||||
const isReady = useMemo(() => {
|
||||
return flagsReady ?? true;
|
||||
}, [flagsReady]);
|
||||
|
||||
const variantName = useMemo(() => {
|
||||
return variant?.name;
|
||||
}, [variant]);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
variant: variantName,
|
||||
payload: variant?.payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет соответствует ли текущий вариант ожидаемым значениям
|
||||
*/
|
||||
export function checkUnleashVariant(
|
||||
currentVariant: string | undefined,
|
||||
expectedVariants: string[],
|
||||
operator: "includesAny" | "includesAll" | "includesExactly" | "equals" = "includesAny"
|
||||
): boolean {
|
||||
if (!currentVariant || expectedVariants.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "includesAny":
|
||||
return expectedVariants.includes(currentVariant);
|
||||
|
||||
case "includesAll":
|
||||
// Для одного варианта "все" означает что он должен быть в списке
|
||||
return expectedVariants.includes(currentVariant);
|
||||
|
||||
case "includesExactly":
|
||||
// Проверяем что есть ровно один вариант и он совпадает
|
||||
return expectedVariants.length === 1 && expectedVariants[0] === currentVariant;
|
||||
|
||||
case "equals":
|
||||
// Точное совпадение с первым вариантом
|
||||
return expectedVariants.length === 1 && expectedVariants[0] === currentVariant;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { matchesNavigationConditions } from "./navigation";
|
||||
import { matchesNavigationConditions, type UnleashChecker } from "./navigation";
|
||||
import type {
|
||||
FunnelAnswers,
|
||||
ScreenDefinition,
|
||||
@ -62,7 +62,8 @@ function applyScreenOverrides<T extends ScreenDefinition>(
|
||||
export function resolveScreenVariant<T extends ScreenDefinition>(
|
||||
screen: T,
|
||||
answers: FunnelAnswers,
|
||||
allScreens?: ScreenDefinition[]
|
||||
allScreens?: ScreenDefinition[],
|
||||
unleashChecker?: UnleashChecker
|
||||
): T {
|
||||
const variants = (screen as T & { variants?: ScreenVariantDefinition<T>[] }).variants;
|
||||
|
||||
@ -71,8 +72,8 @@ export function resolveScreenVariant<T extends ScreenDefinition>(
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
// Передаем allScreens для правильной проверки условий
|
||||
if (matchesNavigationConditions(variant.conditions, answers, allScreens)) {
|
||||
// Передаем allScreens и unleashChecker для правильной проверки условий
|
||||
if (matchesNavigationConditions(variant.conditions, answers, allScreens, unleashChecker)) {
|
||||
return applyScreenOverrides(screen, variant.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,7 +118,7 @@ const NavigationConditionSchema = new Schema(
|
||||
screenId: { type: String, required: true },
|
||||
conditionType: {
|
||||
type: String,
|
||||
enum: ["options", "values"],
|
||||
enum: ["options", "values", "unleash"],
|
||||
default: "options",
|
||||
},
|
||||
operator: {
|
||||
@ -128,6 +128,9 @@ const NavigationConditionSchema = new Schema(
|
||||
},
|
||||
optionIds: [{ type: String }],
|
||||
values: [{ type: String }],
|
||||
// Unleash AB testing fields
|
||||
unleashFlag: { type: String },
|
||||
unleashVariants: [{ type: String }],
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
5
src/types/gtag.d.ts
vendored
Normal file
5
src/types/gtag.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Google Analytics gtag типы
|
||||
* Расширяем существующий интерфейс из services/analytics/types.ts
|
||||
*/
|
||||
export {};
|
||||
Loading…
Reference in New Issue
Block a user