trial choice

This commit is contained in:
dev.daminik00 2025-10-23 02:02:49 +02:00
parent 123e105987
commit 91211ddfbf
20 changed files with 5066 additions and 2324 deletions

View File

@ -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>
),
],
};

View 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

View File

View 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 используются

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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>
);

View File

@ -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>

View File

@ -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);
}}
/>

View File

@ -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:

View File

@ -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',
},
};

View File

@ -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();
});
});

View 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>
);
}

View 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 для стилизации

View File

@ -0,0 +1 @@
export { ProfileCreated } from './ProfileCreated';

View 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;
}

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
export {
PaymentPlacementProvider,
usePaymentPlacementContext,
} from "./PaymentPlacementProvider";
export {
TrialVariantSelectionProvider,
useTrialVariantSelection,
} from "./TrialVariantSelectionContext";

View File

@ -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