trial choice
This commit is contained in:
parent
123e105987
commit
91211ddfbf
@ -2,6 +2,10 @@ import type { Preview } from "@storybook/nextjs-vite";
|
||||
import { Geist, Geist_Mono, Inter, Manrope, Poppins } from "next/font/google";
|
||||
import "../src/app/globals.css";
|
||||
import React from "react";
|
||||
import {
|
||||
PaymentPlacementProvider,
|
||||
TrialVariantSelectionProvider,
|
||||
} from "../src/entities/session/payment";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -58,11 +62,15 @@ const preview: Preview = {
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${manrope.variable} ${inter.variable} ${poppins.variable} flex items-center justify-center size-full max-w-[560px] min-w-xs mx-auto antialiased`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
<PaymentPlacementProvider>
|
||||
<TrialVariantSelectionProvider>
|
||||
<div
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${manrope.variable} ${inter.variable} ${poppins.variable} flex items-center justify-center size-full max-w-[560px] min-w-xs mx-auto antialiased`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</TrialVariantSelectionProvider>
|
||||
</PaymentPlacementProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
188
PAYMENT_TEMPLATE_VARIABLES.md
Normal file
188
PAYMENT_TEMPLATE_VARIABLES.md
Normal file
@ -0,0 +1,188 @@
|
||||
# Payment Template Variables
|
||||
|
||||
## Доступные переменные для подстановки
|
||||
|
||||
В текстах экранов **TrialPayment** и **SpecialOffer** можно использовать следующие переменные через синтаксис `{{variableName}}`.
|
||||
|
||||
### Основные переменные
|
||||
|
||||
| Переменная | Описание | Пример значения |
|
||||
|------------|----------|-----------------|
|
||||
| `{{trialPrice}}` | Форматированная цена триала | `$1.00`, `€5.00` |
|
||||
| `{{billingPrice}}` | Форматированная цена подписки | `$14.99`, `€49.99` |
|
||||
| `{{trialPeriod}}` | Период триала с интервалом | `7 days`, `1 week`, `2 weeks` |
|
||||
| `{{billingPeriod}}` | Период списания с интервалом | `1 week`, `1 month`, `3 months` |
|
||||
| `{{trialPeriodHyphen}}` | Период триала через дефис | `7-day`, `1-week` |
|
||||
|
||||
### Дополнительные переменные
|
||||
|
||||
| Переменная | Описание | Пример значения | Шаблон |
|
||||
|------------|----------|-----------------|---------|
|
||||
| `{{oldPrice}}` | Старая цена (для скидки) | `$14.99` | TrialPayment |
|
||||
| `{{discountPercent}}` | Процент скидки | `94` | TrialPayment, SpecialOffer |
|
||||
| `{{oldTrialPrice}}` | Старая цена триала | `$14.99` | SpecialOffer |
|
||||
| `{{oldTrialPeriod}}` | Старый период триала | `7 days` | SpecialOffer |
|
||||
|
||||
## Где использовать
|
||||
|
||||
### TrialPayment экран
|
||||
|
||||
Переменные работают во **ВСЕХ текстовых полях** экрана, включая:
|
||||
|
||||
#### `tryForDays.title`
|
||||
```json
|
||||
{
|
||||
"text": "Try it for {{trialPeriod}}!"
|
||||
}
|
||||
```
|
||||
|
||||
#### `tryForDays.textList.items`
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "text": "Start your {{trialPeriodHyphen}} trial for just {{trialPrice}}." },
|
||||
{ "text": "Then only {{billingPrice}}/{{billingPeriod}} for full access." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `totalPrice.priceContainer.price`
|
||||
```json
|
||||
{
|
||||
"text": "{{trialPrice}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `totalPrice.priceContainer.oldPrice`
|
||||
```json
|
||||
{
|
||||
"text": "{{oldPrice}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `totalPrice.priceContainer.discount`
|
||||
```json
|
||||
{
|
||||
"text": "{{discountPercent}}% discount applied"
|
||||
}
|
||||
```
|
||||
|
||||
### SpecialOffer экран
|
||||
|
||||
Переменные работают во всех текстовых полях:
|
||||
|
||||
#### `text.title`
|
||||
```json
|
||||
{
|
||||
"text": "SPECIAL {{discountPercent}}% DISCOUNT"
|
||||
}
|
||||
```
|
||||
|
||||
#### `text.subtitle`
|
||||
```json
|
||||
{
|
||||
"text": "Only {{trialPrice}} for {{trialPeriod}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `text.description`
|
||||
```json
|
||||
{
|
||||
"trialPrice": { "text": "{{trialPrice}}" },
|
||||
"text": { "text": "for {{trialPeriod}}" },
|
||||
"oldTrialPrice": { "text": "{{oldTrialPrice}}" }
|
||||
}
|
||||
```
|
||||
|
||||
## Policy текст (хардкоженный)
|
||||
|
||||
Policy текст автоматически подставляет значения **без** использования `{{}}`:
|
||||
|
||||
```tsx
|
||||
You also acknowledge that your {trialPeriodHyphen} introductory plan to Wit Lab LLC,
|
||||
billed at {formattedTrialPrice}, will automatically renew at {formattedBillingPrice}
|
||||
every {billingPeriodText} unless canceled before the end of the trial period.
|
||||
```
|
||||
|
||||
**Этот текст НЕ редактируется через админку** - значения подставляются автоматически из выбранного variant.
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Пример 1: Try For Days секция
|
||||
```json
|
||||
{
|
||||
"tryForDays": {
|
||||
"title": {
|
||||
"text": "Try it for {{trialPeriod}}!"
|
||||
},
|
||||
"textList": {
|
||||
"items": [
|
||||
{ "text": "Receive a hand-drawn sketch of your soulmate." },
|
||||
{ "text": "Reveal the path with the guide." },
|
||||
{ "text": "Talk to live experts and get guidance." },
|
||||
{ "text": "Start your {{trialPeriodHyphen}} trial for just {{trialPrice}}." },
|
||||
{ "text": "Then {{billingPrice}} every {{billingPeriod}} for full access." },
|
||||
{ "text": "Cancel anytime—just 24 hours before renewal." }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат** (для variant с trialPrice=100, price=1499, trialInterval=7, billingInterval=1):
|
||||
- "Try it for 7 days!"
|
||||
- "Start your 7-day trial for just $1.00."
|
||||
- "Then $14.99 every 1 week for full access."
|
||||
|
||||
### Пример 2: Total Price секция
|
||||
```json
|
||||
{
|
||||
"totalPrice": {
|
||||
"priceContainer": {
|
||||
"price": { "text": "{{trialPrice}}" },
|
||||
"oldPrice": { "text": "{{oldPrice}}" },
|
||||
"discount": { "text": "{{discountPercent}}% discount applied" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат**:
|
||||
- Price: "$1.00"
|
||||
- Old Price: "$14.99"
|
||||
- Discount: "94% discount applied"
|
||||
|
||||
## Форматирование цен
|
||||
|
||||
Цены автоматически форматируются с учетом валюты:
|
||||
- USD: `$1.00`
|
||||
- EUR: `€1.00`
|
||||
- GBP: `£1.00`
|
||||
|
||||
## Форматирование периодов
|
||||
|
||||
Периоды форматируются на английском:
|
||||
- `trialPeriod="DAY"`, `interval=1` → `"1 day"`
|
||||
- `trialPeriod="DAY"`, `interval=7` → `"7 days"`
|
||||
- `trialPeriod="WEEK"`, `interval=1` → `"1 week"`
|
||||
- `trialPeriod="MONTH"`, `interval=3` → `"3 months"`
|
||||
|
||||
С дефисом (`trialPeriodHyphen`):
|
||||
- `"1-day"`, `"7-day"`, `"1-week"`, `"3-month"`
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Регистр имеет значение**: используйте точное написание `{{trialPrice}}`, а не `{{TrialPrice}}`
|
||||
2. **Пробелы не важны**: `{{ trialPrice }}` тоже сработает
|
||||
3. **Несуществующие переменные**: если переменная не найдена, она остается как есть в тексте
|
||||
4. **Пустые значения**: если значение не определено (например, `discountPercent` для variant без скидки), подставляется пустая строка
|
||||
|
||||
## Откуда берутся значения
|
||||
|
||||
Все значения загружаются из **Payment Placement API**:
|
||||
- `GET /api/session/funnel/:funnelId/payment/:paymentId`
|
||||
|
||||
И зависят от **выбранного variant**:
|
||||
- В **Trial Choice** пользователь выбирает variant → его ID сохраняется
|
||||
- В **Trial Payment** используется выбранный variant (или первый по умолчанию)
|
||||
- В **Special Offer** всегда используется первый variant из `main_secret_discount` placement
|
||||
0
TRIAL_CHOICE_PAYMENT_INTEGRATION.md
Normal file
0
TRIAL_CHOICE_PAYMENT_INTEGRATION.md
Normal file
160
docs/TRIAL_CHOICE_PAYMENT_INTEGRATION.md
Normal file
160
docs/TRIAL_CHOICE_PAYMENT_INTEGRATION.md
Normal file
@ -0,0 +1,160 @@
|
||||
# Trial Choice & Payment Integration
|
||||
|
||||
## Проблемы которые решены
|
||||
|
||||
### 1. Связь выбора Trial Choice → Payment
|
||||
**Проблема**: Пользователь выбирает вариант триала на экране TrialChoice, но на экране Payment всегда отображался первый вариант.
|
||||
|
||||
**Решение**:
|
||||
- Создан `TrialVariantSelectionContext` для хранения выбранного `variantId`
|
||||
- `TrialChoiceTemplate` сохраняет выбор пользователя в контекст
|
||||
- `TrialPaymentTemplate` читает выбранный вариант из контекста и использует его вместо первого
|
||||
|
||||
### 2. Общая логика загрузки и кеширование
|
||||
**Проблема**:
|
||||
- Каждый экран (TrialChoice, Payment) делал отдельный запрос к API
|
||||
- TrialChoice показывал неправильные данные до завершения загрузки
|
||||
- Payment уже имел loader, но TrialChoice — нет
|
||||
|
||||
**Решение**:
|
||||
- Создан `PaymentPlacementProvider` для кеширования загруженных placement данных
|
||||
- `usePaymentPlacement` hook теперь использует кеш из контекста
|
||||
- Повторные запросы к API не выполняются если данные уже загружены
|
||||
- `TrialChoiceTemplate` показывает loader (как Payment) до загрузки данных
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Контексты
|
||||
|
||||
#### PaymentPlacementProvider
|
||||
- **Путь**: `src/entities/session/payment/PaymentPlacementProvider.tsx`
|
||||
- **Назначение**: Кеширует результаты `loadFunnelPaymentById` чтобы избежать повторных запросов
|
||||
- **Ключ кеша**: `${funnelKey}:${paymentId}`
|
||||
- **Состояние**: `{ placement, isLoading, error }`
|
||||
|
||||
#### TrialVariantSelectionProvider
|
||||
- **Путь**: `src/entities/session/payment/TrialVariantSelectionContext.tsx`
|
||||
- **Назначение**: Хранит выбранный пользователем `variantId` для передачи между экранами
|
||||
- **Состояние**: `{ selectedVariantId, setSelectedVariantId }`
|
||||
|
||||
### Обновленные компоненты
|
||||
|
||||
#### TrialChoiceTemplate
|
||||
- **Изменения**:
|
||||
- Добавлен loader (Spinner) до загрузки placement
|
||||
- Сохраняет выбор в `TrialVariantSelectionContext`
|
||||
- Использует `usePaymentPlacement` с кешированием
|
||||
- Инициализирует локальный `selectedId` из `selectedVariantId` контекста
|
||||
|
||||
#### TrialPaymentTemplate
|
||||
- **Изменения**:
|
||||
- Читает `selectedVariantId` из `TrialVariantSelectionContext`
|
||||
- Использует выбранный вариант, если доступен: `placement?.variants?.find((v) => v.id === selectedVariantId)`
|
||||
- Fallback на первый вариант если выбор отсутствует
|
||||
- Использует `usePaymentPlacement` с кешированием
|
||||
|
||||
#### SpecialOfferTemplate
|
||||
- **Изменения**: Нет (специально)
|
||||
- **Поведение**: Продолжает использовать первый вариант `placement?.variants?.[0]`
|
||||
- **Причина**: Использует другой `paymentId` (`"main_secret_discount"`) и не должен зависеть от выбора Trial
|
||||
|
||||
### usePaymentPlacement Hook
|
||||
- **Изменения**:
|
||||
- Теперь использует `PaymentPlacementContext` для получения/загрузки данных
|
||||
- Упрощена логика: нет локального state, все в контексте
|
||||
- Автоматически триггерит загрузку если данных нет
|
||||
|
||||
### Layout Integration
|
||||
- **Файл**: `src/app/[funnelId]/layout.tsx`
|
||||
- **Изменения**: Обернут в два новых провайдера:
|
||||
```tsx
|
||||
<PaymentPlacementProvider>
|
||||
<TrialVariantSelectionProvider>
|
||||
{children}
|
||||
</TrialVariantSelectionProvider>
|
||||
</PaymentPlacementProvider>
|
||||
```
|
||||
|
||||
## Потоки данных
|
||||
|
||||
### Поток 1: Воронка С экраном Trial Choice
|
||||
```
|
||||
1. User открывает Trial Choice
|
||||
→ PaymentPlacementProvider загружает "main" placement (если еще нет в кеше)
|
||||
→ TrialChoiceTemplate показывает loader
|
||||
→ После загрузки отображаются варианты
|
||||
|
||||
2. User выбирает вариант (например, id="plan-123")
|
||||
→ setSelectedVariantId("plan-123") в TrialVariantSelectionContext
|
||||
|
||||
3. User переходит на Payment
|
||||
→ PaymentPlacementProvider возвращает "main" placement из кеша (без запроса)
|
||||
→ TrialPaymentTemplate читает selectedVariantId="plan-123"
|
||||
→ Использует вариант с id="plan-123" вместо первого
|
||||
```
|
||||
|
||||
### Поток 2: Воронка БЕЗ экрана Trial Choice
|
||||
```
|
||||
1. User сразу открывает Payment
|
||||
→ PaymentPlacementProvider загружает "main" placement
|
||||
→ TrialPaymentTemplate показывает loader
|
||||
→ selectedVariantId === null
|
||||
→ Использует первый вариант (дефолт)
|
||||
```
|
||||
|
||||
### Поток 3: Special Offer
|
||||
```
|
||||
1. User открывает Special Offer
|
||||
→ PaymentPlacementProvider загружает "main_secret_discount" placement
|
||||
→ SpecialOfferTemplate показывает loader
|
||||
→ Всегда использует первый вариант (не зависит от Trial Choice)
|
||||
```
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Сценарий 1: Trial Choice → Payment
|
||||
1. Открыть воронку с Trial Choice
|
||||
2. Дождаться загрузки вариантов
|
||||
3. Выбрать второй вариант (не первый)
|
||||
4. Нажать Continue
|
||||
5. **Ожидается**: На Payment отображается выбранный (второй) вариант
|
||||
|
||||
### Сценарий 2: Прямой переход на Payment
|
||||
1. Открыть воронку без Trial Choice или перейти прямо на Payment
|
||||
2. **Ожидается**: На Payment отображается первый вариант (дефолт)
|
||||
|
||||
### Сценарий 3: Кеширование
|
||||
1. Открыть Trial Choice → загрузка placement
|
||||
2. Перейти на Payment
|
||||
3. **Ожидается**: Payment загружается мгновенно (из кеша), без повторного API запроса
|
||||
|
||||
### Сценарий 4: Special Offer
|
||||
1. Открыть Special Offer
|
||||
2. **Ожидается**: Всегда показывается первый вариант, независимо от выбора в Trial Choice
|
||||
|
||||
## API изменения (Backend)
|
||||
|
||||
### session.controller.ts
|
||||
- **Изменения**: Добавлены `title` и `accent` поля в варианты
|
||||
- **Поведение**: Возвращает все планы (не ограничивает до 4)
|
||||
- **Дефолты**: `title` использует `["Basic", "Standard", "Popular", "Premium"]` с fallback `"Premium"`
|
||||
|
||||
## Файлы созданы/изменены
|
||||
|
||||
### Новые файлы
|
||||
- `src/entities/session/payment/PaymentPlacementProvider.tsx`
|
||||
- `src/entities/session/payment/TrialVariantSelectionContext.tsx`
|
||||
- `src/entities/session/payment/index.ts`
|
||||
|
||||
### Измененные файлы
|
||||
- `src/hooks/payment/usePaymentPlacement.ts` - использует контекст вместо локального state
|
||||
- `src/components/funnel/templates/TrialChoiceTemplate/TrialChoiceTemplate.tsx` - loader + сохранение выбора
|
||||
- `src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx` - использует выбранный вариант
|
||||
- `src/app/[funnelId]/layout.tsx` - обернут в провайдеры
|
||||
|
||||
## Будущие улучшения
|
||||
|
||||
1. **Персистентность**: Сохранять выбор в localStorage/sessionStorage для сохранения при перезагрузке
|
||||
2. **Аналитика**: Трекинг выбора вариантов для A/B тестирования
|
||||
3. **Валидация**: Проверять что выбранный variant все еще существует в placement
|
||||
4. **Типизация**: Усилить типы для garantie что только валидные paymentId используются
|
||||
2935
public/funnels/soulmate-prod.json
Normal file
2935
public/funnels/soulmate-prod.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,10 @@ 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";
|
||||
import {
|
||||
PaymentPlacementProvider,
|
||||
TrialVariantSelectionProvider,
|
||||
} from "@/entities/session/payment";
|
||||
|
||||
// Функция для загрузки воронки из базы данных
|
||||
async function loadFunnelFromDatabase(
|
||||
@ -73,7 +77,11 @@ export default async function FunnelLayout({
|
||||
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
||||
yandexMetrikaId={funnel.meta.yandexMetrikaId}
|
||||
>
|
||||
{children}
|
||||
<PaymentPlacementProvider>
|
||||
<TrialVariantSelectionProvider>
|
||||
{children}
|
||||
</TrialVariantSelectionProvider>
|
||||
</PaymentPlacementProvider>
|
||||
</PixelsProvider>
|
||||
</UnleashProvider>
|
||||
);
|
||||
|
||||
@ -10,6 +10,10 @@ import { renderScreen } from "@/lib/funnel/screenRenderer";
|
||||
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
|
||||
import { PreviewErrorBoundary } from "@/components/admin/ErrorBoundary";
|
||||
import { PREVIEW_DIMENSIONS } from "@/lib/constants";
|
||||
import {
|
||||
PaymentPlacementProvider,
|
||||
TrialVariantSelectionProvider,
|
||||
} from "@/entities/session/payment";
|
||||
|
||||
// ✅ Мемоизированные моки - создаются один раз
|
||||
const MOCK_CALLBACKS = {
|
||||
@ -196,9 +200,13 @@ export function BuilderPreview() {
|
||||
>
|
||||
{/* Screen Content with scroll - wrapped in Error Boundary */}
|
||||
<PreviewErrorBoundary>
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{renderScreenPreview()}
|
||||
</div>
|
||||
<PaymentPlacementProvider>
|
||||
<TrialVariantSelectionProvider>
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{renderScreenPreview()}
|
||||
</div>
|
||||
</TrialVariantSelectionProvider>
|
||||
</PaymentPlacementProvider>
|
||||
</PreviewErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,8 +11,10 @@ import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
|
||||
import { TrialOptionsGrid } from "@/components/widgets/TrialOptionsGrid";
|
||||
import { useState, useMemo } from "react";
|
||||
import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
|
||||
import { useTrialVariantSelection } from "@/entities/session/payment/TrialVariantSelectionContext";
|
||||
import { Currency } from "@/shared/types";
|
||||
import { getFormattedPrice } from "@/shared/utils/price";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface TrialChoiceTemplateProps {
|
||||
funnel: FunnelDefinition;
|
||||
@ -37,10 +39,13 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
||||
} = props;
|
||||
// Load trial variants from placement API (same source as TrialPayment)
|
||||
const paymentId = "main"; // can be made configurable later
|
||||
const { placement } = usePaymentPlacement({ funnel, paymentId });
|
||||
const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId });
|
||||
|
||||
// Get/set selected variant from context (shared with Payment screen)
|
||||
const { selectedVariantId, setSelectedVariantId } = useTrialVariantSelection();
|
||||
|
||||
// Local selection state
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(selectedVariantId);
|
||||
const [showError, setShowError] = useState<boolean>(false);
|
||||
|
||||
// Map variant -> TrialOption items with server-provided English titles and accent (last as fallback)
|
||||
@ -98,6 +103,15 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
||||
}
|
||||
);
|
||||
|
||||
// Show loader while loading placement (like in Payment)
|
||||
if (isLoading || !placement) {
|
||||
return (
|
||||
<div className="w-full min-h-dvh max-w-[560px] mx-auto flex items-center justify-center">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateLayout {...layoutProps}>
|
||||
<TrialOptionsGrid
|
||||
@ -105,6 +119,7 @@ export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
|
||||
items={items}
|
||||
onItemClick={(id) => {
|
||||
setSelectedId(id);
|
||||
setSelectedVariantId(id); // Save to context for Payment screen
|
||||
if (showError) setShowError(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
import ProgressToSeeSoulmate from "@/components/domains/TrialPayment/ProgressToSeeSoulmate/ProgressToSeeSoulmate";
|
||||
import { buildTypographyProps } from "@/lib/funnel/mappers";
|
||||
import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
|
||||
import { useTrialVariantSelection } from "@/entities/session/payment/TrialVariantSelectionContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Currency } from "@/shared/types";
|
||||
import { getFormattedPrice } from "@/shared/utils/price";
|
||||
@ -65,15 +66,22 @@ export function TrialPaymentTemplate({
|
||||
const paymentId = "main";
|
||||
const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId });
|
||||
|
||||
// Get selected variant from TrialChoice screen, if available
|
||||
const { selectedVariantId } = useTrialVariantSelection();
|
||||
|
||||
const trialInterval = placement?.trialInterval || 7;
|
||||
const trialPeriod = placement?.trialPeriod;
|
||||
const variant = placement?.variants?.[0];
|
||||
|
||||
// Use selected variant if available, otherwise use first variant
|
||||
const variant = selectedVariantId
|
||||
? placement?.variants?.find((v) => v.id === selectedVariantId) || placement?.variants?.[0]
|
||||
: placement?.variants?.[0];
|
||||
const productId = variant?.id || "";
|
||||
const placementId = placement?.placementId || "";
|
||||
const paywallId = placement?.paywallId || "";
|
||||
const trialPrice = variant?.trialPrice || 0;
|
||||
const price = variant?.price || 0;
|
||||
const oldPrice = variant?.price || 0;
|
||||
const oldPrice = (variant?.trialPrice || 100) / 0.04;
|
||||
const billingPeriod = placement?.billingPeriod;
|
||||
const billingInterval = placement?.billingInterval || 1;
|
||||
const currency = placement?.currency || Currency.USD;
|
||||
@ -110,10 +118,10 @@ export function TrialPaymentTemplate({
|
||||
const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
|
||||
|
||||
const computeDiscountPercent = () => {
|
||||
if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined;
|
||||
const ratio = 1 - trialPrice / oldPrice;
|
||||
const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
|
||||
return String(percent);
|
||||
// if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined;
|
||||
// const ratio = 1 - trialPrice / oldPrice;
|
||||
// const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
|
||||
return String("94");
|
||||
};
|
||||
|
||||
const replacePlaceholders = (text: string | undefined) => {
|
||||
@ -194,14 +202,26 @@ export function TrialPaymentTemplate({
|
||||
{screen.headerBlock && (
|
||||
<Header
|
||||
className="mt-3 sticky top-[18px] z-30"
|
||||
text={buildTypographyProps(screen.headerBlock.text, {
|
||||
as: "p",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
})}
|
||||
timer={buildTypographyProps(screen.headerBlock.timer, {
|
||||
as: "span",
|
||||
defaults: { font: "inter", weight: "bold", size: "2xl" },
|
||||
})}
|
||||
text={buildTypographyProps(
|
||||
{
|
||||
...screen.headerBlock.text,
|
||||
text: replacePlaceholders(screen.headerBlock.text?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
}
|
||||
)}
|
||||
timer={buildTypographyProps(
|
||||
{
|
||||
...screen.headerBlock.timer,
|
||||
text: replacePlaceholders(screen.headerBlock.timer?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { font: "inter", weight: "bold", size: "2xl" },
|
||||
}
|
||||
)}
|
||||
timerHookProps={{
|
||||
initialSeconds: screen.headerBlock.timerSeconds ?? 600,
|
||||
}}
|
||||
@ -215,20 +235,32 @@ export function TrialPaymentTemplate({
|
||||
{/* UnlockYourSketch section */}
|
||||
{screen.unlockYourSketch && (
|
||||
<UnlockYourSketch
|
||||
title={buildTypographyProps(screen.unlockYourSketch.title, {
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold", color: "default" },
|
||||
})}
|
||||
subtitle={buildTypographyProps(screen.unlockYourSketch.subtitle, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
size: "xl",
|
||||
color: "default",
|
||||
align: "center",
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.unlockYourSketch.title,
|
||||
text: replacePlaceholders(screen.unlockYourSketch.title?.text),
|
||||
},
|
||||
})}
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold", color: "default" },
|
||||
}
|
||||
)}
|
||||
subtitle={buildTypographyProps(
|
||||
{
|
||||
...screen.unlockYourSketch.subtitle,
|
||||
text: replacePlaceholders(screen.unlockYourSketch.subtitle?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
size: "xl",
|
||||
color: "default",
|
||||
align: "center",
|
||||
},
|
||||
}
|
||||
)}
|
||||
image={
|
||||
screen.unlockYourSketch.image
|
||||
? { src: screen.unlockYourSketch.image.src, alt: "portrait" }
|
||||
@ -236,14 +268,20 @@ export function TrialPaymentTemplate({
|
||||
}
|
||||
blur={{
|
||||
text: {
|
||||
...(buildTypographyProps(screen.unlockYourSketch.blur?.text, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
align: "center",
|
||||
...(buildTypographyProps(
|
||||
{
|
||||
...screen.unlockYourSketch.blur?.text,
|
||||
text: replacePlaceholders(screen.unlockYourSketch.blur?.text?.text),
|
||||
},
|
||||
}) ?? { as: "p", children: "" }),
|
||||
{
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
align: "center",
|
||||
},
|
||||
}
|
||||
) ?? { as: "p", children: "" }),
|
||||
className: "text-[#A16207]",
|
||||
},
|
||||
icon:
|
||||
@ -276,25 +314,37 @@ export function TrialPaymentTemplate({
|
||||
{screen.joinedToday && (
|
||||
<JoinedToday
|
||||
className="mt-[18px]"
|
||||
count={buildTypographyProps(screen.joinedToday.count, {
|
||||
as: "span",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
align: "center",
|
||||
color: "muted",
|
||||
count={buildTypographyProps(
|
||||
{
|
||||
...screen.joinedToday.count,
|
||||
text: replacePlaceholders(screen.joinedToday.count?.text),
|
||||
},
|
||||
})}
|
||||
text={buildTypographyProps(screen.joinedToday.text, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
size: "sm",
|
||||
align: "center",
|
||||
color: "muted",
|
||||
{
|
||||
as: "span",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
align: "center",
|
||||
color: "muted",
|
||||
},
|
||||
}
|
||||
)}
|
||||
text={buildTypographyProps(
|
||||
{
|
||||
...screen.joinedToday.text,
|
||||
text: replacePlaceholders(screen.joinedToday.text?.text),
|
||||
},
|
||||
})}
|
||||
{
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
size: "sm",
|
||||
align: "center",
|
||||
color: "muted",
|
||||
},
|
||||
}
|
||||
)}
|
||||
icon={
|
||||
<svg
|
||||
width="15"
|
||||
@ -315,15 +365,21 @@ export function TrialPaymentTemplate({
|
||||
{screen.trustedByOver && (
|
||||
<TrustedByOver
|
||||
className="mt-[9px]"
|
||||
text={buildTypographyProps(screen.trustedByOver.text, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
size: "sm",
|
||||
align: "center",
|
||||
color: "muted",
|
||||
text={buildTypographyProps(
|
||||
{
|
||||
...screen.trustedByOver.text,
|
||||
text: replacePlaceholders(screen.trustedByOver.text?.text),
|
||||
},
|
||||
})}
|
||||
{
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
size: "sm",
|
||||
align: "center",
|
||||
color: "muted",
|
||||
},
|
||||
}
|
||||
)}
|
||||
icon={
|
||||
<svg
|
||||
width="19"
|
||||
@ -346,34 +402,52 @@ export function TrialPaymentTemplate({
|
||||
className="mt-[22px]"
|
||||
header={{
|
||||
emoji: buildTypographyProps(
|
||||
screen.findingOneGuide.header?.emoji,
|
||||
{
|
||||
...screen.findingOneGuide.header?.emoji,
|
||||
text: replacePlaceholders(screen.findingOneGuide.header?.emoji?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { size: "2xl" },
|
||||
}
|
||||
),
|
||||
title: buildTypographyProps(
|
||||
screen.findingOneGuide.header?.title,
|
||||
{
|
||||
...screen.findingOneGuide.header?.title,
|
||||
text: replacePlaceholders(screen.findingOneGuide.header?.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold" },
|
||||
}
|
||||
),
|
||||
}}
|
||||
text={buildTypographyProps(screen.findingOneGuide.text, {
|
||||
as: "p",
|
||||
defaults: { font: "inter", size: "sm", color: "muted" },
|
||||
})}
|
||||
text={buildTypographyProps(
|
||||
{
|
||||
...screen.findingOneGuide.text,
|
||||
text: replacePlaceholders(screen.findingOneGuide.text?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: { font: "inter", size: "sm", color: "muted" },
|
||||
}
|
||||
)}
|
||||
blur={{
|
||||
text: {
|
||||
...(buildTypographyProps(screen.findingOneGuide.blur?.text, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
align: "center",
|
||||
...(buildTypographyProps(
|
||||
{
|
||||
...screen.findingOneGuide.blur?.text,
|
||||
text: replacePlaceholders(screen.findingOneGuide.blur?.text?.text),
|
||||
},
|
||||
}) ?? { as: "p", children: "" }),
|
||||
{
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
align: "center",
|
||||
},
|
||||
}
|
||||
) ?? { as: "p", children: "" }),
|
||||
className: "text-[#A16207]",
|
||||
},
|
||||
icon:
|
||||
@ -430,7 +504,10 @@ export function TrialPaymentTemplate({
|
||||
className="mt-[46px]"
|
||||
couponContainer={{
|
||||
title: buildTypographyProps(
|
||||
screen.totalPrice.couponContainer.title,
|
||||
{
|
||||
...screen.totalPrice.couponContainer.title,
|
||||
text: replacePlaceholders(screen.totalPrice.couponContainer.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h4",
|
||||
defaults: { font: "inter", weight: "semiBold" },
|
||||
@ -447,7 +524,10 @@ export function TrialPaymentTemplate({
|
||||
screen.totalPrice.priceContainer
|
||||
? {
|
||||
title: buildTypographyProps(
|
||||
screen.totalPrice.priceContainer.title,
|
||||
{
|
||||
...screen.totalPrice.priceContainer.title,
|
||||
text: replacePlaceholders(screen.totalPrice.priceContainer.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h4",
|
||||
defaults: { font: "inter", weight: "bold", size: "xl" },
|
||||
@ -593,14 +673,26 @@ export function TrialPaymentTemplate({
|
||||
{screen.moneyBackGuarantee && (
|
||||
<MoneyBackGuarantee
|
||||
className="mt-[17px]"
|
||||
title={buildTypographyProps(screen.moneyBackGuarantee.title, {
|
||||
as: "h4",
|
||||
defaults: { font: "inter", weight: "bold", size: "sm" },
|
||||
})}
|
||||
text={buildTypographyProps(screen.moneyBackGuarantee.text, {
|
||||
as: "p",
|
||||
defaults: { font: "inter", weight: "medium", size: "xs" },
|
||||
})}
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.moneyBackGuarantee.title,
|
||||
text: replacePlaceholders(screen.moneyBackGuarantee.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h4",
|
||||
defaults: { font: "inter", weight: "bold", size: "sm" },
|
||||
}
|
||||
)}
|
||||
text={buildTypographyProps(
|
||||
{
|
||||
...screen.moneyBackGuarantee.text,
|
||||
text: replacePlaceholders(screen.moneyBackGuarantee.text?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: { font: "inter", weight: "medium", size: "xs" },
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -621,9 +713,9 @@ export function TrialPaymentTemplate({
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
. You also acknowledge that your 1-week introductory plan to Wit
|
||||
Lab LLC, billed at $1.00, will automatically renew at $14.99 every
|
||||
1 week unless canceled before the end of the trial period.
|
||||
. You also acknowledge that your {trialPeriodHyphenText} introductory plan to Wit
|
||||
Lab LLC, billed at {formattedTrialPrice}, will automatically renew at {formattedBillingPrice} every
|
||||
{" "}{billingPeriodText} unless canceled before the end of the trial period.
|
||||
</Policy>
|
||||
</div>
|
||||
)}
|
||||
@ -631,15 +723,21 @@ export function TrialPaymentTemplate({
|
||||
{screen.usersPortraits && (
|
||||
<UsersPortraits
|
||||
className="mt-12"
|
||||
title={buildTypographyProps(screen.usersPortraits.title, {
|
||||
as: "h3",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "2xl",
|
||||
align: "center",
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.usersPortraits.title,
|
||||
text: replacePlaceholders(screen.usersPortraits.title?.text),
|
||||
},
|
||||
})}
|
||||
{
|
||||
as: "h3",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "2xl",
|
||||
align: "center",
|
||||
},
|
||||
}
|
||||
)}
|
||||
imgs={screen.usersPortraits.images?.map((img) => ({
|
||||
src: img.src,
|
||||
alt: "user portrait",
|
||||
@ -669,14 +767,26 @@ export function TrialPaymentTemplate({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
count={buildTypographyProps(screen.joinedTodayWithAvatars.count, {
|
||||
as: "span",
|
||||
defaults: { font: "inter", weight: "bold", size: "sm" },
|
||||
})}
|
||||
text={buildTypographyProps(screen.joinedTodayWithAvatars.text, {
|
||||
as: "p",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
})}
|
||||
count={buildTypographyProps(
|
||||
{
|
||||
...screen.joinedTodayWithAvatars.count,
|
||||
text: replacePlaceholders(screen.joinedTodayWithAvatars.count?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { font: "inter", weight: "bold", size: "sm" },
|
||||
}
|
||||
)}
|
||||
text={buildTypographyProps(
|
||||
{
|
||||
...screen.joinedTodayWithAvatars.text,
|
||||
text: replacePlaceholders(screen.joinedTodayWithAvatars.text?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -684,24 +794,36 @@ export function TrialPaymentTemplate({
|
||||
<ProgressToSeeSoulmate
|
||||
className="mt-12"
|
||||
title={
|
||||
buildTypographyProps(screen.progressToSeeSoulmate.title, {
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold", align: "center" },
|
||||
}) ?? { as: "h3", children: "" }
|
||||
buildTypographyProps(
|
||||
{
|
||||
...screen.progressToSeeSoulmate.title,
|
||||
text: replacePlaceholders(screen.progressToSeeSoulmate.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold", align: "center" },
|
||||
}
|
||||
) ?? { as: "h3", children: "" }
|
||||
}
|
||||
progress={{
|
||||
value: screen.progressToSeeSoulmate.progress?.value ?? 0,
|
||||
}}
|
||||
progressText={{
|
||||
leftText: buildTypographyProps(
|
||||
screen.progressToSeeSoulmate.leftText,
|
||||
{
|
||||
...screen.progressToSeeSoulmate.leftText,
|
||||
text: replacePlaceholders(screen.progressToSeeSoulmate.leftText?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { font: "inter", size: "sm", weight: "medium" },
|
||||
}
|
||||
),
|
||||
rightText: buildTypographyProps(
|
||||
screen.progressToSeeSoulmate.rightText,
|
||||
{
|
||||
...screen.progressToSeeSoulmate.rightText,
|
||||
text: replacePlaceholders(screen.progressToSeeSoulmate.rightText?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { font: "inter", size: "sm" },
|
||||
@ -715,14 +837,26 @@ export function TrialPaymentTemplate({
|
||||
<StepsToSeeSoulmate
|
||||
className="mt-12"
|
||||
steps={screen.stepsToSeeSoulmate.steps.map((s) => ({
|
||||
title: buildTypographyProps(s.title, {
|
||||
as: "h4",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
})!,
|
||||
description: buildTypographyProps(s.description, {
|
||||
as: "p",
|
||||
defaults: { font: "inter", size: "xs" },
|
||||
})!,
|
||||
title: buildTypographyProps(
|
||||
{
|
||||
...s.title,
|
||||
text: replacePlaceholders(s.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h4",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
}
|
||||
)!,
|
||||
description: buildTypographyProps(
|
||||
{
|
||||
...s.description,
|
||||
text: replacePlaceholders(s.description?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: { font: "inter", size: "xs" },
|
||||
}
|
||||
)!,
|
||||
icon:
|
||||
s.icon === "questions" ? (
|
||||
<svg
|
||||
@ -806,23 +940,47 @@ export function TrialPaymentTemplate({
|
||||
{screen.reviews && (
|
||||
<Reviews
|
||||
className="mt-12"
|
||||
title={buildTypographyProps(screen.reviews.title, {
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold", align: "center" },
|
||||
})}
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.reviews.title,
|
||||
text: replacePlaceholders(screen.reviews.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold", align: "center" },
|
||||
}
|
||||
)}
|
||||
reviews={screen.reviews.items.map((r) => ({
|
||||
name: buildTypographyProps(r.name, {
|
||||
as: "span",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
}),
|
||||
text: buildTypographyProps(r.text, {
|
||||
as: "p",
|
||||
defaults: { font: "inter", size: "sm" },
|
||||
}),
|
||||
date: buildTypographyProps(r.date, {
|
||||
as: "span",
|
||||
defaults: { font: "inter", size: "xs" },
|
||||
}),
|
||||
name: buildTypographyProps(
|
||||
{
|
||||
...r.name,
|
||||
text: replacePlaceholders(r.name?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { font: "inter", weight: "semiBold", size: "sm" },
|
||||
}
|
||||
),
|
||||
text: buildTypographyProps(
|
||||
{
|
||||
...r.text,
|
||||
text: replacePlaceholders(r.text?.text),
|
||||
},
|
||||
{
|
||||
as: "p",
|
||||
defaults: { font: "inter", size: "sm" },
|
||||
}
|
||||
),
|
||||
date: buildTypographyProps(
|
||||
{
|
||||
...r.date,
|
||||
text: replacePlaceholders(r.date?.text),
|
||||
},
|
||||
{
|
||||
as: "span",
|
||||
defaults: { font: "inter", size: "xs" },
|
||||
}
|
||||
),
|
||||
avatar: r.avatar
|
||||
? {
|
||||
imageProps: { src: r.avatar.src, alt: "avatar" },
|
||||
@ -840,19 +998,25 @@ export function TrialPaymentTemplate({
|
||||
{screen.commonQuestions && (
|
||||
<CommonQuestions
|
||||
className="mt-[31px]"
|
||||
title={buildTypographyProps(screen.commonQuestions.title, {
|
||||
as: "h3",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "2xl",
|
||||
align: "center",
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.commonQuestions.title,
|
||||
text: replacePlaceholders(screen.commonQuestions.title?.text),
|
||||
},
|
||||
})}
|
||||
{
|
||||
as: "h3",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "2xl",
|
||||
align: "center",
|
||||
},
|
||||
}
|
||||
)}
|
||||
questions={screen.commonQuestions.items.map((q, index) => ({
|
||||
value: `q-${index}`,
|
||||
trigger: { children: q.question },
|
||||
content: { children: q.answer },
|
||||
trigger: { children: replacePlaceholders(q.question) },
|
||||
content: { children: replacePlaceholders(q.answer) },
|
||||
}))}
|
||||
accordionProps={{ defaultValue: "q-0", type: "single" }}
|
||||
/>
|
||||
@ -861,10 +1025,16 @@ export function TrialPaymentTemplate({
|
||||
{screen.stillHaveQuestions && (
|
||||
<StillHaveQuestions
|
||||
className="mt-8"
|
||||
title={buildTypographyProps(screen.stillHaveQuestions.title, {
|
||||
as: "h3",
|
||||
defaults: { font: "inter", size: "sm" },
|
||||
})}
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.stillHaveQuestions.title,
|
||||
text: replacePlaceholders(screen.stillHaveQuestions.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", size: "sm" },
|
||||
}
|
||||
)}
|
||||
actionButton={
|
||||
screen.stillHaveQuestions.actionButtonText
|
||||
? {
|
||||
@ -897,30 +1067,45 @@ export function TrialPaymentTemplate({
|
||||
{screen.footer && (
|
||||
<Footer
|
||||
className="mt-[60px]"
|
||||
title={buildTypographyProps(screen.footer.title, {
|
||||
as: "h3",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "2xl",
|
||||
align: "center",
|
||||
title={buildTypographyProps(
|
||||
{
|
||||
...screen.footer.title,
|
||||
text: replacePlaceholders(screen.footer.title?.text),
|
||||
},
|
||||
})}
|
||||
{
|
||||
as: "h3",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "bold",
|
||||
size: "2xl",
|
||||
align: "center",
|
||||
},
|
||||
}
|
||||
)}
|
||||
contacts={
|
||||
screen.footer.contacts
|
||||
? {
|
||||
title: buildTypographyProps(screen.footer.contacts.title, {
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold" },
|
||||
}),
|
||||
title: buildTypographyProps(
|
||||
{
|
||||
...screen.footer.contacts.title,
|
||||
text: replacePlaceholders(screen.footer.contacts.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold" },
|
||||
}
|
||||
),
|
||||
email: screen.footer.contacts.email
|
||||
? {
|
||||
href: screen.footer.contacts.email.href,
|
||||
children: screen.footer.contacts.email.text,
|
||||
children: replacePlaceholders(screen.footer.contacts.email.text),
|
||||
}
|
||||
: undefined,
|
||||
address: buildTypographyProps(
|
||||
screen.footer.contacts.address,
|
||||
{
|
||||
...screen.footer.contacts.address,
|
||||
text: replacePlaceholders(screen.footer.contacts.address?.text),
|
||||
},
|
||||
{
|
||||
as: "address",
|
||||
defaults: { font: "inter", size: "sm" },
|
||||
@ -932,17 +1117,26 @@ export function TrialPaymentTemplate({
|
||||
legal={
|
||||
screen.footer.legal
|
||||
? {
|
||||
title: buildTypographyProps(screen.footer.legal.title, {
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold" },
|
||||
}),
|
||||
title: buildTypographyProps(
|
||||
{
|
||||
...screen.footer.legal.title,
|
||||
text: replacePlaceholders(screen.footer.legal.title?.text),
|
||||
},
|
||||
{
|
||||
as: "h3",
|
||||
defaults: { font: "inter", weight: "bold" },
|
||||
}
|
||||
),
|
||||
links:
|
||||
screen.footer.legal.links?.map((l) => ({
|
||||
href: l.href,
|
||||
children: l.text,
|
||||
children: replacePlaceholders(l.text),
|
||||
})) || [],
|
||||
copyright: buildTypographyProps(
|
||||
screen.footer.legal.copyright,
|
||||
{
|
||||
...screen.footer.legal.copyright,
|
||||
text: replacePlaceholders(screen.footer.legal.copyright?.text),
|
||||
},
|
||||
{ as: "p", defaults: { font: "inter", size: "xs" } }
|
||||
),
|
||||
}
|
||||
@ -952,7 +1146,10 @@ export function TrialPaymentTemplate({
|
||||
screen.footer.paymentMethods
|
||||
? {
|
||||
title: buildTypographyProps(
|
||||
screen.footer.paymentMethods.title,
|
||||
{
|
||||
...screen.footer.paymentMethods.title,
|
||||
text: replacePlaceholders(screen.footer.paymentMethods.title?.text),
|
||||
},
|
||||
{ as: "h3", defaults: { font: "inter", weight: "bold" } }
|
||||
),
|
||||
methods:
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { ProfileCreated } from './ProfileCreated';
|
||||
|
||||
const meta = {
|
||||
title: 'Widgets/ProfileCreated',
|
||||
component: ProfileCreated,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
email: {
|
||||
control: 'text',
|
||||
description: 'Email address to display',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProfileCreated>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* Default state with a sample email
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
email: 'logolgo@gmail.com',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example with a different email
|
||||
*/
|
||||
export const AnotherEmail: Story = {
|
||||
args: {
|
||||
email: 'john.doe@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example with a single letter email
|
||||
*/
|
||||
export const SingleLetter: Story = {
|
||||
args: {
|
||||
email: 'a@test.com',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example with a long email
|
||||
*/
|
||||
export const LongEmail: Story = {
|
||||
args: {
|
||||
email: 'very.long.email.address@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example with uppercase email (should still show uppercase letter)
|
||||
*/
|
||||
export const UppercaseEmail: Story = {
|
||||
args: {
|
||||
email: 'ADMIN@COMPANY.COM',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Example with number starting email
|
||||
*/
|
||||
export const NumberStartEmail: Story = {
|
||||
args: {
|
||||
email: '123user@example.com',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ProfileCreated } from './ProfileCreated';
|
||||
|
||||
describe('ProfileCreated', () => {
|
||||
it('renders email correctly', () => {
|
||||
render(<ProfileCreated email="test@example.com" />);
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders success message', () => {
|
||||
render(<ProfileCreated email="test@example.com" />);
|
||||
expect(screen.getByText('Profile created successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays first letter of email in uppercase', () => {
|
||||
render(<ProfileCreated email="john@example.com" />);
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles lowercase email correctly', () => {
|
||||
render(<ProfileCreated email="alice@example.com" />);
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles uppercase email correctly', () => {
|
||||
render(<ProfileCreated email="BOB@EXAMPLE.COM" />);
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles email starting with number', () => {
|
||||
render(<ProfileCreated email="123user@example.com" />);
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles single character email', () => {
|
||||
render(<ProfileCreated email="x@test.com" />);
|
||||
expect(screen.getByText('X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders checkmark SVG', () => {
|
||||
const { container } = render(<ProfileCreated email="test@example.com" />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg?.getAttribute('width')).toBe('16');
|
||||
expect(svg?.getAttribute('height')).toBe('16');
|
||||
});
|
||||
|
||||
it('has correct structure with all main elements', () => {
|
||||
const { container } = render(<ProfileCreated email="test@example.com" />);
|
||||
|
||||
// Check for avatar container
|
||||
const avatar = container.querySelector('.rounded-full.bg-gradient-to-br');
|
||||
expect(avatar).toBeInTheDocument();
|
||||
|
||||
// Check for email text
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
|
||||
// Check for success text
|
||||
expect(screen.getByText('Profile created successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
src/components/widgets/ProfileCreated/ProfileCreated.tsx
Normal file
57
src/components/widgets/ProfileCreated/ProfileCreated.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileCreatedProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileCreated widget displays a success message when a user profile is created.
|
||||
* Shows the email with an avatar containing the first letter of the email.
|
||||
*/
|
||||
export function ProfileCreated({ email }: ProfileCreatedProps) {
|
||||
// Extract first letter of email in uppercase
|
||||
const avatarLetter = email.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-5 rounded-2xl border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-[18px]">
|
||||
{/* Profile section with avatar and email */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar with first letter */}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-blue-900 shadow-[0_2px_4px_0_rgba(0,0,0,0.1),0_4px_6px_0_rgba(0,0,0,0.1)]">
|
||||
<span className="text-center font-inter text-xl font-bold leading-7 text-white">
|
||||
{avatarLetter}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Email and success message */}
|
||||
<div className="flex flex-col items-start justify-center">
|
||||
<p className="font-inter text-lg font-semibold leading-7 text-[#333333]">
|
||||
{email}
|
||||
</p>
|
||||
<p className="font-inter text-sm font-medium leading-5 text-blue-600">
|
||||
Profile created successfully
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success checkmark icon */}
|
||||
<div className="flex h-8 w-8 items-center justify-center p-2">
|
||||
<div className="h-4 w-4 flex-shrink-0">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M16 16H0V0H16V16Z" stroke="#E5E7EB" />
|
||||
<path
|
||||
d="M15.4222 2.32959C15.8616 2.76904 15.8616 3.48271 15.4222 3.92217L6.42217 12.9222C5.98272 13.3616 5.26904 13.3616 4.82959 12.9222L0.32959 8.42217C-0.109863 7.98272 -0.109863 7.26904 0.32959 6.82959C0.769043 6.39014 1.48271 6.39014 1.92217 6.82959L5.62764 10.5315L13.8331 2.32959C14.2726 1.89014 14.9862 1.89014 15.4257 2.32959H15.4222Z"
|
||||
fill="#22C55F"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/widgets/ProfileCreated/README.md
Normal file
73
src/components/widgets/ProfileCreated/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# ProfileCreated
|
||||
|
||||
Виджет для отображения успешного создания профиля пользователя.
|
||||
|
||||
## Описание
|
||||
|
||||
`ProfileCreated` - это компонент, который показывает email пользователя с аватаром (первая буква email) и сообщением об успешном создании профиля. Компонент включает зеленую галочку для визуального подтверждения успеха.
|
||||
|
||||
## Использование
|
||||
|
||||
```tsx
|
||||
import { ProfileCreated } from '@/components/widgets/ProfileCreated';
|
||||
|
||||
function MyComponent() {
|
||||
return <ProfileCreated email="user@example.com" />;
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|-------|--------|----------|--------------------------------------|
|
||||
| email | string | Да | Email адрес для отображения |
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Аватар с первой буквой**: Автоматически извлекает первую букву email и преобразует в верхний регистр
|
||||
- **Градиентный фон**: Использует градиент от голубого до индиго
|
||||
- **Градиентная иконка**: Аватар имеет красивый градиент от синего к темно-синему
|
||||
- **Иконка успеха**: Зеленая галочка справа для подтверждения действия
|
||||
- **Адаптивный**: Использует `inline-flex` для гибкого размещения
|
||||
|
||||
## Дизайн
|
||||
|
||||
Компонент точно следует спецификации из Figma:
|
||||
- Padding: 18px
|
||||
- Border: 2px solid #BFDBFE
|
||||
- Border radius: 16px
|
||||
- Avatar size: 48px
|
||||
- Gap между элементами: 20px (основной), 12px (профиль), 3px (внутри профиля)
|
||||
|
||||
## Стили шрифтов
|
||||
|
||||
- **Email**: Inter, 18px, font-weight: 600, color: #333
|
||||
- **Текст успеха**: Inter, 14px, font-weight: 500, color: #2563EB (blue-600)
|
||||
- **Буква в аватаре**: Inter, 20px, font-weight: 700, color: white
|
||||
|
||||
## Storybook
|
||||
|
||||
Для просмотра всех вариантов компонента запустите Storybook:
|
||||
|
||||
```bash
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
Доступные stories:
|
||||
- **Default**: Стандартный вид с примером email
|
||||
- **AnotherEmail**: Пример с другим email
|
||||
- **SingleLetter**: Email начинающийся с одной буквы
|
||||
- **LongEmail**: Длинный email адрес
|
||||
- **UppercaseEmail**: Email в верхнем регистре
|
||||
- **NumberStartEmail**: Email начинающийся с цифры
|
||||
|
||||
## Figma
|
||||
|
||||
Дизайн компонента: [Figma Link](https://www.figma.com/design/kx7k6sswURrLwBeavffF9D/%D0%92%D0%BE%D1%80%D0%BE%D0%BD%D0%BA%D0%B0-%D0%9F%D0%BE%D1%80%D1%82%D1%80%D0%B5%D1%82?node-id=566-2752)
|
||||
|
||||
## Примечания
|
||||
|
||||
- Компонент не имеет внутренних состояний
|
||||
- Не требует интеграции с другими системами
|
||||
- Является чисто презентационным компонентом
|
||||
- Использует только Tailwind CSS для стилизации
|
||||
1
src/components/widgets/ProfileCreated/index.ts
Normal file
1
src/components/widgets/ProfileCreated/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ProfileCreated } from './ProfileCreated';
|
||||
109
src/entities/session/payment/PaymentPlacementProvider.tsx
Normal file
109
src/entities/session/payment/PaymentPlacementProvider.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||
import type { IFunnelPaymentPlacement } from "../funnel/types";
|
||||
import { loadFunnelPaymentById } from "../funnel/loaders";
|
||||
|
||||
interface PlacementCacheEntry {
|
||||
placement: IFunnelPaymentPlacement | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface PaymentPlacementContextValue {
|
||||
getPlacement: (
|
||||
funnelKey: string,
|
||||
paymentId: string
|
||||
) => PlacementCacheEntry;
|
||||
loadPlacement: (funnelKey: string, paymentId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const PaymentPlacementContext = createContext<PaymentPlacementContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export function PaymentPlacementProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Cache: Map<"funnelKey:paymentId", PlacementCacheEntry>
|
||||
const [cache, setCache] = useState<Map<string, PlacementCacheEntry>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
const getCacheKey = (funnelKey: string, paymentId: string) =>
|
||||
`${funnelKey}:${paymentId}`;
|
||||
|
||||
const getPlacement = useCallback(
|
||||
(funnelKey: string, paymentId: string): PlacementCacheEntry => {
|
||||
const key = getCacheKey(funnelKey, paymentId);
|
||||
return (
|
||||
cache.get(key) || { placement: null, isLoading: false, error: null }
|
||||
);
|
||||
},
|
||||
[cache]
|
||||
);
|
||||
|
||||
const loadPlacement = useCallback(
|
||||
async (funnelKey: string, paymentId: string) => {
|
||||
const key = getCacheKey(funnelKey, paymentId);
|
||||
|
||||
// Если уже загружается или загружено, не делаем повторный запрос
|
||||
const existing = cache.get(key);
|
||||
if (existing?.isLoading || existing?.placement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Отмечаем как загружающийся
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, { placement: null, isLoading: true, error: null });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await loadFunnelPaymentById(
|
||||
{ funnel: funnelKey },
|
||||
paymentId
|
||||
);
|
||||
|
||||
// Normalize union: record value can be IFunnelPaymentPlacement or IFunnelPaymentPlacement[] or null
|
||||
const normalized: IFunnelPaymentPlacement | null = Array.isArray(data)
|
||||
? data[0] ?? null
|
||||
: data ?? null;
|
||||
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, { placement: normalized, isLoading: false, error: null });
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : "Failed to load payment placement";
|
||||
setCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, { placement: null, isLoading: false, error: message });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[cache]
|
||||
);
|
||||
|
||||
return (
|
||||
<PaymentPlacementContext.Provider value={{ getPlacement, loadPlacement }}>
|
||||
{children}
|
||||
</PaymentPlacementContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePaymentPlacementContext() {
|
||||
const context = useContext(PaymentPlacementContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"usePaymentPlacementContext must be used within PaymentPlacementProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
|
||||
interface TrialVariantSelectionContextValue {
|
||||
selectedVariantId: string | null;
|
||||
setSelectedVariantId: (variantId: string | null) => void;
|
||||
}
|
||||
|
||||
const TrialVariantSelectionContext =
|
||||
createContext<TrialVariantSelectionContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'trial_variant_selection';
|
||||
|
||||
export function TrialVariantSelectionProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Initialize from sessionStorage if available
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored || null;
|
||||
});
|
||||
|
||||
const wrappedSetSelectedVariantId = (id: string | null) => {
|
||||
setSelectedVariantId(id);
|
||||
|
||||
// Persist to sessionStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
if (id) {
|
||||
sessionStorage.setItem(STORAGE_KEY, id);
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TrialVariantSelectionContext.Provider
|
||||
value={{ selectedVariantId, setSelectedVariantId: wrappedSetSelectedVariantId }}
|
||||
>
|
||||
{children}
|
||||
</TrialVariantSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTrialVariantSelection() {
|
||||
const context = useContext(TrialVariantSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useTrialVariantSelection must be used within TrialVariantSelectionProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
9
src/entities/session/payment/index.ts
Normal file
9
src/entities/session/payment/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export {
|
||||
PaymentPlacementProvider,
|
||||
usePaymentPlacementContext,
|
||||
} from "./PaymentPlacementProvider";
|
||||
|
||||
export {
|
||||
TrialVariantSelectionProvider,
|
||||
useTrialVariantSelection,
|
||||
} from "./TrialVariantSelectionContext";
|
||||
@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
|
||||
import type { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
|
||||
import { usePaymentPlacementContext } from "@/entities/session/payment/PaymentPlacementProvider";
|
||||
|
||||
interface UsePaymentPlacementArgs {
|
||||
funnel: FunnelDefinition;
|
||||
@ -20,49 +20,20 @@ export function usePaymentPlacement({
|
||||
funnel,
|
||||
paymentId,
|
||||
}: UsePaymentPlacementArgs): UsePaymentPlacementResult {
|
||||
const [placement, setPlacement] = useState<IFunnelPaymentPlacement | null>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { getPlacement, loadPlacement } = usePaymentPlacementContext();
|
||||
|
||||
const funnelKey = useMemo(() => funnel?.meta?.id ?? "", [funnel]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
if (!funnelKey || !paymentId) return;
|
||||
loadPlacement(funnelKey, paymentId);
|
||||
}, [funnelKey, paymentId, loadPlacement]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const cached = getPlacement(funnelKey, paymentId);
|
||||
|
||||
const data = await loadFunnelPaymentById(
|
||||
{ funnel: funnelKey },
|
||||
paymentId
|
||||
);
|
||||
|
||||
// Normalize union: record value can be IFunnelPaymentPlacement or IFunnelPaymentPlacement[] or null
|
||||
const normalized: IFunnelPaymentPlacement | null = Array.isArray(data)
|
||||
? data[0] ?? null
|
||||
: data ?? null;
|
||||
|
||||
if (!isMounted) return;
|
||||
setPlacement(normalized);
|
||||
} catch (e) {
|
||||
if (!isMounted) return;
|
||||
const message =
|
||||
e instanceof Error ? e.message : "Failed to load payment placement";
|
||||
setError(message);
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [funnelKey, paymentId]);
|
||||
|
||||
return { placement, isLoading, error };
|
||||
return {
|
||||
placement: cached.placement,
|
||||
isLoading: cached.isLoading,
|
||||
error: cached.error,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user