Merge pull request #41 from WIT-LAB-LLC/trial

Trial
This commit is contained in:
pennyteenycat 2025-10-23 02:51:52 +02:00 committed by GitHub
commit 1ee0951481
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 5957 additions and 2515 deletions

View File

@ -2,6 +2,12 @@ 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";
import type { IFunnelPaymentPlacement } from "../src/entities/session/funnel/types";
import { Currency } from "../src/shared/types";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -31,6 +37,24 @@ const poppins = Poppins({
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
});
// Storybook mock placement to avoid network calls
const storybookPlacement: IFunnelPaymentPlacement = {
currency: Currency.USD,
billingPeriod: "WEEK",
billingInterval: 1,
trialPeriod: "DAY",
trialInterval: 7,
placementId: "plc_story",
paywallId: "pw_story",
paymentUrl: "https://example.com/pay",
variants: [
{ id: "v1", key: "basic", type: "subscription", price: 1499, trialPrice: 100, title: "Basic" },
{ id: "v2", key: "standard", type: "subscription", price: 1499, trialPrice: 499, title: "Standard" },
{ id: "v3", key: "popular", type: "subscription", price: 1499, trialPrice: 899, title: "Popular", accent: true },
{ id: "v4", key: "premium", type: "subscription", price: 1499, trialPrice: 1367, title: "Premium" },
],
};
const preview: Preview = {
parameters: {
controls: {
@ -58,11 +82,19 @@ 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`}
<PaymentPlacementProvider
initialCache={[
{ funnelKey: "storybook-funnel", paymentId: "main", placement: storybookPlacement },
]}
>
<Story />
</div>
<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

309
package-lock.json generated
View File

@ -38,11 +38,11 @@
"devDependencies": {
"@chromatic-com/storybook": "^4.1.1",
"@eslint/eslintrc": "^3",
"@storybook/addon-a11y": "^9.1.6",
"@storybook/addon-docs": "^9.1.6",
"@storybook/addon-a11y": "^9.1.13",
"@storybook/addon-docs": "^9.1.13",
"@storybook/addon-styling-webpack": "^2.0.0",
"@storybook/addon-vitest": "^9.1.6",
"@storybook/nextjs-vite": "^9.1.6",
"@storybook/addon-vitest": "^9.1.13",
"@storybook/nextjs-vite": "^9.1.13",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@ -51,9 +51,9 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"eslint-plugin-storybook": "^9.1.6",
"eslint-plugin-storybook": "^9.1.13",
"playwright": "^1.55.0",
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5",
@ -1605,53 +1605,6 @@
}
}
},
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -3109,9 +3062,9 @@
"license": "MIT"
},
"node_modules/@storybook/addon-a11y": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.6.tgz",
"integrity": "sha512-jpuzbZlT8G1hx4N6nhhmxy6Lu+Xnz1oeGb2/pm+rKx2fZ4oy7yGRliRNOvpTy8MbkpnfMoLLrcqc66s/kfdf3A==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.13.tgz",
"integrity": "sha512-4enIl1h2XSZnFKUQJJoZbp1X40lzdj7f5JE15ZhU1al4z6hHWp7i2zD7ySyDpEbMypBCz1xnLvyiyw79m1fp7w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3123,20 +3076,20 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^9.1.6"
"storybook": "^9.1.13"
}
},
"node_modules/@storybook/addon-docs": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.6.tgz",
"integrity": "sha512-4ZE/T2Ayw77/v2ersAk/VM7vlvqV2zCNFwt0uvOzUR1VZ9VqZCHhsfy/IyBPeKt6Otax3EpfE1LkH4slfceB0g==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.13.tgz",
"integrity": "sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "9.1.6",
"@storybook/csf-plugin": "9.1.13",
"@storybook/icons": "^1.4.0",
"@storybook/react-dom-shim": "9.1.6",
"@storybook/react-dom-shim": "9.1.13",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@ -3146,7 +3099,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^9.1.6"
"storybook": "^9.1.13"
}
},
"node_modules/@storybook/addon-styling-webpack": {
@ -3161,9 +3114,9 @@
}
},
"node_modules/@storybook/addon-vitest": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-9.1.6.tgz",
"integrity": "sha512-I5kev4ZfJFP4ScTV7cfA1PMqSantcfNBio5xzO0edoMXBWugPrD22M2Z2kF+odieHGYXsQy8bc56F4KdAUiu4w==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-9.1.13.tgz",
"integrity": "sha512-g/wkQ8i1GGlsoHEe6bjWic+ESokWhuMBxAa9FDLW9KDf0L1DMyQqFFJFnGoo99zCNRVJcSXgzZTFp6SCt3FKog==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3179,7 +3132,7 @@
"peerDependencies": {
"@vitest/browser": "^3.0.0",
"@vitest/runner": "^3.0.0",
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"vitest": "^3.0.0"
},
"peerDependenciesMeta": {
@ -3195,13 +3148,13 @@
}
},
"node_modules/@storybook/builder-vite": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.6.tgz",
"integrity": "sha512-AUoSjXr4MvtkFQkfFfZSXrqVM0z80DX0sebm80nODu/qFhsJIU5trNP+XDYY8ClODERXd5QSZJyOyH9nOz60SA==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.13.tgz",
"integrity": "sha512-pmtIjU02ASJOZKdL8DoxWXJgZnpTDgD5WmMnjKJh9FaWmc2YiCW2Y6VRxPox96OM655jYHQe5+UIbk3Cwtwb4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/csf-plugin": "9.1.6",
"@storybook/csf-plugin": "9.1.13",
"ts-dedent": "^2.0.0"
},
"funding": {
@ -3209,14 +3162,14 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@storybook/csf-plugin": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.6.tgz",
"integrity": "sha512-cz4Y+OYCtuovFNwoLkIKk0T62clrRTYf26Bbo1gdIGuX/W3JPP/LnN97sP2/0nfF6heZqCdEwb47k7RubkxXZg==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.13.tgz",
"integrity": "sha512-EMpzYuyt9FDcxxfBChWzfId50y8QMpdenviEQ8m+pa6c+ANx3pC5J6t7y0khD8TQu815sTy+nc6cc8PC45dPUA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3227,7 +3180,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "^9.1.6"
"storybook": "^9.1.13"
}
},
"node_modules/@storybook/global": {
@ -3252,17 +3205,17 @@
}
},
"node_modules/@storybook/nextjs-vite": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/nextjs-vite/-/nextjs-vite-9.1.6.tgz",
"integrity": "sha512-BdzgPiuOKTl5q+NgCJjpwuFYL/me0u69zpZ7w4EhVLs4pxyJtUO7JRW8OW5VyBp3/M2ryoqWU0dPAfQ4WzrHAA==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/nextjs-vite/-/nextjs-vite-9.1.13.tgz",
"integrity": "sha512-iUQbfAndUag5ehPPldvVCM8T4GylOEZqf13EEHvjgh8F33vOiANq7WTGbvO+aBpBNug3x+1pG1hmKXRKVmA6xQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-vite": "9.1.6",
"@storybook/react": "9.1.6",
"@storybook/react-vite": "9.1.6",
"@storybook/builder-vite": "9.1.13",
"@storybook/react": "9.1.13",
"@storybook/react-vite": "9.1.13",
"styled-jsx": "5.1.6",
"vite-plugin-storybook-nextjs": "^2.0.5"
"vite-plugin-storybook-nextjs": "^2.0.7"
},
"engines": {
"node": ">=20.0.0"
@ -3275,7 +3228,7 @@
"next": "^14.1.0 || ^15.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
@ -3285,14 +3238,14 @@
}
},
"node_modules/@storybook/react": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.6.tgz",
"integrity": "sha512-BGf3MQaXj6LmYnYpSwHUoWH0RP6kaqBoPc2u5opSU2ajw34enIL5w2sFaXzL+k2ap0aHnCYYlyBINBBvtD6NIA==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.13.tgz",
"integrity": "sha512-B0UpYikKf29t8QGcdmumWojSQQ0phSDy/Ne2HYdrpNIxnUvHHUVOlGpq4lFcIDt52Ip5YG5GuAwJg3+eR4LCRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "9.1.6"
"@storybook/react-dom-shim": "9.1.13"
},
"engines": {
"node": ">=20.0.0"
@ -3304,7 +3257,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@ -3314,9 +3267,9 @@
}
},
"node_modules/@storybook/react-dom-shim": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.6.tgz",
"integrity": "sha512-Px4duzPMTPqI3kes6eUyYjWpEeJ0AOCCeSDCBDm9rzlf4a+eXlxfhkcVWft3viCDiIkc0vtYagb2Yu7bcSIypg==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.13.tgz",
"integrity": "sha512-/tMr9TmV3+98GEQO0S03k4gtKHGCpv9+k9Dmnv+TJK3TBz7QsaFEzMwe3gCgoTaebLACyVveDiZkWnCYAWB6NA==",
"dev": true,
"license": "MIT",
"funding": {
@ -3326,20 +3279,20 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"storybook": "^9.1.6"
"storybook": "^9.1.13"
}
},
"node_modules/@storybook/react-vite": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.6.tgz",
"integrity": "sha512-YNKQZcz5Vtv8OdHUJ65Wx4PbfZMrPPbtL+OYAR0We+EEoTDofi3VogXyOUw99Jppp1HIq5IiDF5qyZPEpC5k0A==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.13.tgz",
"integrity": "sha512-mV1bZ1bpkNQygnuDo1xMGAS5ZXuoXFF0WGmr/BzNDGmRhZ1K1HQh42kC0w3PklckFBUwCFxmP58ZwTFzf+/dJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1",
"@rollup/pluginutils": "^5.0.2",
"@storybook/builder-vite": "9.1.6",
"@storybook/react": "9.1.6",
"@storybook/builder-vite": "9.1.13",
"@storybook/react": "9.1.13",
"find-up": "^7.0.0",
"magic-string": "^0.30.0",
"react-docgen": "^8.0.0",
@ -3356,23 +3309,10 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta",
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@storybook/react-vite/node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@storybook/react-vite/node_modules/find-up": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz",
@ -3462,28 +3402,6 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@storybook/react-vite/node_modules/react-docgen": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.1.tgz",
"integrity": "sha512-kQKsqPLplY3Hx4jGnM3jpQcG3FQDt7ySz32uTHt3C9HAe45kNXG+3o16Eqn3Fw1GtMfHoN3b4J/z2e6cZJCmqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.2",
"@types/babel__core": "^7.20.5",
"@types/babel__traverse": "^7.20.7",
"@types/doctrine": "^0.0.9",
"@types/resolve": "^1.20.2",
"doctrine": "^3.0.0",
"resolve": "^1.22.1",
"strip-indent": "^4.0.0"
},
"engines": {
"node": "^20.9.0 || >=22"
}
},
"node_modules/@storybook/react-vite/node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
@ -6856,9 +6774,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-9.1.6.tgz",
"integrity": "sha512-4NLf8lOT7Nl+m9aipVHJczyt/Dp6BzHzyNq4nhaEUjoZFGKMhPa52vSbuLyQYX7IrcrYPlM37X8dFGo/EIE9JA==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-9.1.13.tgz",
"integrity": "sha512-kPuhbtGDiJLB5OLZuwFZAxgzWakNDw64sJtXUPN8g0+VAeXfHyZEmsE28qIIETHxtal71lPKVm8QNnERaJHPJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6869,7 +6787,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "^9.1.6"
"storybook": "^9.1.13"
}
},
"node_modules/eslint-scope": {
@ -7357,6 +7275,27 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -7378,6 +7317,32 @@
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@ -9696,6 +9661,28 @@
"react": ">=0.14.0"
}
},
"node_modules/react-docgen": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.2.tgz",
"integrity": "sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.2",
"@types/babel__core": "^7.20.5",
"@types/babel__traverse": "^7.20.7",
"@types/doctrine": "^0.0.9",
"@types/resolve": "^1.20.2",
"doctrine": "^3.0.0",
"resolve": "^1.22.1",
"strip-indent": "^4.0.0"
},
"engines": {
"node": "^20.9.0 || >=22"
}
},
"node_modules/react-docgen-typescript": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz",
@ -9706,6 +9693,19 @@
"typescript": ">= 4.3.x"
}
},
"node_modules/react-docgen/node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -10598,9 +10598,9 @@
}
},
"node_modules/storybook": {
"version": "9.1.6",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.6.tgz",
"integrity": "sha512-iIcMaDKkjR5nN+JYBy9hhoxZhjX4TXhyJgUBed+toJOlfrl+QvxpBjImAi7qKyLR3hng3uoigEP0P8+vYtXpOg==",
"version": "9.1.13",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz",
"integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -10854,9 +10854,9 @@
}
},
"node_modules/strip-indent": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.0.tgz",
"integrity": "sha512-OA95x+JPmL7kc7zCu+e+TeYxEiaIyndRx0OrBcK2QPPH09oAndr2ALvymxWA+Lx1PYYvFUm4O63pRkdJAaW96w==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz",
"integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==",
"dev": true,
"license": "MIT",
"engines": {
@ -11086,27 +11086,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",

View File

@ -50,11 +50,11 @@
"devDependencies": {
"@chromatic-com/storybook": "^4.1.1",
"@eslint/eslintrc": "^3",
"@storybook/addon-a11y": "^9.1.6",
"@storybook/addon-docs": "^9.1.6",
"@storybook/addon-a11y": "^9.1.13",
"@storybook/addon-docs": "^9.1.13",
"@storybook/addon-styling-webpack": "^2.0.0",
"@storybook/addon-vitest": "^9.1.6",
"@storybook/nextjs-vite": "^9.1.6",
"@storybook/addon-vitest": "^9.1.13",
"@storybook/nextjs-vite": "^9.1.13",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
@ -63,9 +63,9 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"eslint-plugin-storybook": "^9.1.6",
"eslint-plugin-storybook": "^9.1.13",
"playwright": "^1.55.0",
"storybook": "^9.1.6",
"storybook": "^9.1.13",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5",

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

@ -11,6 +11,7 @@ import type {
} from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { DropIndicator } from "./DropIndicator";
import { InsertScreenButton } from "./InsertScreenButton";
import { TransitionRow } from "./TransitionRow";
import { TemplateSummary } from "./TemplateSummary";
import { VariantSummary } from "./VariantSummary";
@ -24,6 +25,8 @@ export function BuilderCanvas() {
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
const [insertScreenDialogOpen, setInsertScreenDialogOpen] = useState(false);
const [insertAtIndex, setInsertAtIndex] = useState<number | null>(null);
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
event.dataTransfer.effectAllowed = "move";
@ -115,6 +118,17 @@ export function BuilderCanvas() {
dispatch({ type: "add-screen", payload: { template } });
}, [dispatch]);
const handleInsertScreen = useCallback((atIndex: number) => {
setInsertAtIndex(atIndex);
setInsertScreenDialogOpen(true);
}, []);
const handleInsertScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
if (insertAtIndex !== null) {
dispatch({ type: "insert-screen", payload: { template, atIndex: insertAtIndex } });
}
}, [dispatch, insertAtIndex]);
const screenTitleMap = useMemo(() => {
return screens.reduce<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title?.text || screen.id;
@ -159,10 +173,15 @@ export function BuilderCanvas() {
const defaultTargetIndex = defaultNext
? screens.findIndex((candidate) => candidate.id === defaultNext)
: null;
const onBackScreenId = screen.navigation?.onBackScreenId;
const backTargetIndex = onBackScreenId
? screens.findIndex((candidate) => candidate.id === onBackScreenId)
: null;
return (
<div key={screen.id} className="relative">
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div>
<div
className={cn(
@ -217,6 +236,15 @@ export function BuilderCanvas() {
</div>
<div className="space-y-3">
{onBackScreenId && (
<TransitionRow
type="back"
label="← Переход назад"
targetLabel={screenTitleMap[onBackScreenId] ?? onBackScreenId}
targetIndex={backTargetIndex !== -1 ? backTargetIndex : null}
/>
)}
<TransitionRow
type={
screen.navigation?.isEndScreen
@ -275,6 +303,10 @@ export function BuilderCanvas() {
</div>
</div>
</div>
{/* Insert button after each screen */}
<InsertScreenButton onInsert={() => handleInsertScreen(index + 1)} />
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
</div>
);
@ -301,6 +333,12 @@ export function BuilderCanvas() {
onOpenChange={setAddScreenDialogOpen}
onAddScreen={handleAddScreenWithTemplate}
/>
<AddScreenDialog
open={insertScreenDialogOpen}
onOpenChange={setInsertScreenDialogOpen}
onAddScreen={handleInsertScreenWithTemplate}
/>
</>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState } from "react";
interface InsertScreenButtonProps {
onInsert: () => void;
}
export function InsertScreenButton({ onInsert }: InsertScreenButtonProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="group relative flex h-0 items-center justify-center transition-all"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Hover area - wider for easier interaction */}
<div className="absolute inset-0 h-12 -translate-y-1/2 -mx-8" />
{/* Divider line */}
<div
className={cn(
"absolute left-0 right-0 h-px transition-all",
isHovered ? "bg-primary/40" : "bg-transparent"
)}
/>
{/* Insert button */}
<button
type="button"
onClick={onInsert}
className={cn(
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border-2 bg-background shadow-sm transition-all",
isHovered
? "scale-110 border-primary/50 text-primary hover:bg-primary hover:text-primary-foreground"
: "scale-90 border-border/40 text-muted-foreground opacity-0 group-hover:opacity-100"
)}
aria-label="Вставить экран"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
);
}

View File

@ -1,8 +1,8 @@
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
import { ArrowDown, ArrowRight, ArrowLeft, CircleSlash2, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TransitionRowProps {
type: "default" | "branch" | "end";
type: "default" | "branch" | "end" | "back";
label: string;
targetLabel?: string;
targetIndex?: number | null;
@ -18,7 +18,7 @@ export function TransitionRow({
optionSummaries = [],
operator,
}: TransitionRowProps) {
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : type === "back" ? ArrowLeft : ArrowDown;
return (
<div
@ -26,13 +26,19 @@ export function TransitionRow({
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
type === "branch"
? "border-primary/40 bg-primary/5"
: "border-border/60 bg-background/90"
: type === "back"
? "border-orange-400/40 bg-orange-50/50 dark:bg-orange-950/20"
: "border-border/60 bg-background/90"
)}
>
<div
className={cn(
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
type === "branch"
? "bg-primary text-primary-foreground"
: type === "back"
? "bg-orange-500 text-white dark:bg-orange-600"
: "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
@ -42,7 +48,11 @@ export function TransitionRow({
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
type === "branch" ? "text-primary" : "text-muted-foreground"
type === "branch"
? "text-primary"
: type === "back"
? "text-orange-600 dark:text-orange-400"
: "text-muted-foreground"
)}
>
{label}

View File

@ -13,6 +13,7 @@ export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
loaders: "Загрузка",
soulmate: "Портрет партнера",
trialPayment: "Trial Payment",
trialChoice: "Trial Choice",
specialOffer: "Special Offer",
};

View File

@ -3,6 +3,7 @@ export { BuilderCanvas } from "./BuilderCanvas";
// Sub-components
export { DropIndicator } from "./DropIndicator";
export { InsertScreenButton } from "./InsertScreenButton";
export { TransitionRow } from "./TransitionRow";
export { TemplateSummary } from "./TemplateSummary";
export { VariantSummary } from "./VariantSummary";

View File

@ -12,6 +12,7 @@ import {
Mail,
CreditCard,
Gift,
Layers,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -99,6 +100,14 @@ const TEMPLATE_OPTIONS = [
color:
"bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400",
},
{
template: "trialChoice" as const,
title: "Trial Choice",
description: "Экран выбора вариантов пробного периода",
icon: Layers,
color:
"bg-lime-50 text-lime-700 dark:bg-lime-900/20 dark:text-lime-400",
},
{
template: "specialOffer" as const,
title: "Special Offer",

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

@ -294,18 +294,16 @@ interface HeaderControlsProps {
}
function HeaderControls({ header, onChange }: HeaderControlsProps) {
const activeHeader = header ?? { show: true, showBackButton: true };
const handleToggle = (field: "show" | "showBackButton", checked: boolean) => {
if (field === "show" && !checked) {
onChange({
...activeHeader,
show: false,
showBackButton: false,
});
return;
}
const activeHeader = header ?? {
show: true,
showBackButton: true,
showProgress: true
};
const handleToggle = (
field: "show" | "showBackButton" | "showProgress",
checked: boolean
) => {
onChange({
...activeHeader,
[field]: checked,
@ -320,11 +318,22 @@ function HeaderControls({ header, onChange }: HeaderControlsProps) {
checked={activeHeader.show !== false}
onChange={(event) => handleToggle("show", event.target.checked)}
/>
Показывать шапку с прогрессом
Показывать шапку экрана
</label>
{activeHeader.show !== false && (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activeHeader.showProgress !== false}
onChange={(event) =>
handleToggle("showProgress", event.target.checked)
}
/>
Показывать прогресс бар
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"

View File

@ -0,0 +1,50 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { TrialChoiceTemplate } from "./TrialChoiceTemplate";
import { fn } from "storybook/test";
import { buildTrialChoiceDefaults } from "@/lib/admin/builder/state/defaults/trialChoice";
import type { TrialChoiceScreenDefinition, FunnelDefinition } from "@/lib/funnel/types";
// Используем дефолтные значения из builder, чтобы сторибук совпадал с админкой
const defaultScreen = buildTrialChoiceDefaults(
"trial-choice-story"
) as TrialChoiceScreenDefinition;
// Minimal funnel mock for storybook (used to derive placement id in hooks)
const mockFunnel: FunnelDefinition = {
meta: { id: "storybook-funnel" },
defaultTexts: { nextButton: "Next", continueButton: "Continue" },
screens: [],
};
/** TrialChoiceTemplate — базовый экран с выбором пробного периода */
const meta: Meta<typeof TrialChoiceTemplate> = {
title: "Funnel Templates/TrialChoiceTemplate",
component: TrialChoiceTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
funnel: mockFunnel,
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
defaultTexts: {
continueButton: "Next",
},
answers: {},
},
argTypes: {
screen: { control: { type: "object" } },
screenProgress: { control: { type: "object" } },
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Базовый Trial Choice экран */
export const Default: Story = {};

View File

@ -0,0 +1,225 @@
"use client";
import type {
TrialChoiceScreenDefinition,
DefaultTexts,
FunnelAnswers,
FunnelDefinition,
} from "@/lib/funnel/types";
import { TemplateLayout } from "@/components/funnel/templates/layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import { TrialOptionsGrid } from "@/components/widgets/TrialOptionsGrid";
import { ProfileCreated } from "@/components/widgets/ProfileCreated";
import { useState, useMemo, useRef } 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;
screen: TrialChoiceScreenDefinition;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
answers: FunnelAnswers;
}
export function TrialChoiceTemplate(props: TrialChoiceTemplateProps) {
const {
funnel,
screen,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
answers,
} = props;
// Load trial variants from placement API (same source as TrialPayment)
const paymentId = "main"; // can be made configurable later
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>(selectedVariantId);
const [showError, setShowError] = useState<boolean>(false);
// Ref для прокрутки к вариантам триала
const trialOptionsRef = useRef<HTMLDivElement | null>(null);
// Получаем email из answers воронки
// Email хранится как массив строк под ключом с ID email экрана
// Сначала ищем email экран по template, затем берем его ID
const emailScreen = funnel.screens.find(s => s.template === 'email');
const emailScreenId = emailScreen?.id || 'email'; // fallback на 'email' для обратной совместимости
const email = answers[emailScreenId]?.[0] || 'user@example.com';
// Map variant -> TrialOption items with server-provided English titles and accent (last as fallback)
const items = useMemo(() => {
const currency = placement?.currency || Currency.USD;
const variants = placement?.variants ?? [];
const TITLES = ["Basic", "Standard", "Popular", "Premium"] as const;
const list = (variants.length ? variants : new Array(4).fill(null)).map(
(v, index) => {
const id = v?.id ?? `stub-${index}`;
const trialPrice = v?.trialPrice ?? (index + 1) * 100; // cents stub
const value = getFormattedPrice(trialPrice, currency);
const title = v?.title ?? TITLES[index] ?? TITLES[TITLES.length - 1];
return { id, value, title };
}
);
const lastIndex = Math.max(0, list.length - 1);
return list.map((it, index) => {
const variant = variants[index];
const accentFromApi = variant?.accent === true;
const state =
selectedId === it.id
? "selected"
: accentFromApi || index === lastIndex
? "accent"
: "default";
// When user clicks disabled button, we flag all options with error until a selection is made
const error = showError && selectedId == null;
return { ...it, state, error } as const;
});
}, [placement, selectedId, showError]);
const isActionDisabled = selectedId == null;
const layoutProps = createTemplateLayoutProps(
screen,
{ canGoBack, onBack },
screenProgress,
{
preset: "center",
actionButton: {
defaultText:
screen.bottomActionButton?.text ||
defaultTexts?.continueButton ||
"Next",
disabled: isActionDisabled,
onClick: onContinue,
onDisabledClick: () => {
if (isActionDisabled) {
setShowError(true);
// Прокручиваем к вариантам триала
if (trialOptionsRef.current) {
trialOptionsRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
}
},
},
}
);
// 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}
contentProps={{ className: "p-0 pt-0" }}
childrenWrapperProps={{ className: "mt-0" }}
>
<div className="flex flex-col w-full px-6 pb-[82px]">
{/* Виджет созданного профиля - без верхнего отступа */}
<ProfileCreated email={email} />
{/* Первый текстовый блок */}
<p className="text-[#2C2C2C] text-center font-inter text-[15px] font-medium leading-[125%] mt-[14px]">
You&apos;re just one step away from discovering the face of your true soulmate the person whose energy aligns perfectly with yours. This personalized experience, created by professionals through a precise analysis of your energy, compatibility, and subconscious love patterns, also includes the &quot;Finding the One Guide&quot; a profound journey that reveals why your past relationships were the way they were and how to finally open yourself to real, lasting love. It has already helped millions find their true partner and harmony in love.
<br />
<br />
Your portrait and guide which could completely transform how you see love are almost ready! Before we send them, we invite you to choose the amount you feel is fair to unlock access.
</p>
{/* Второй текстовый блок (список) */}
<div className="text-[#2C2C2C] font-inter text-[15px] font-medium leading-[125%] mt-[14px]">
<ul className="space-y-3 list-disc pl-6">
<li>See the face of the person energetically connected to you the one truly meant for you;</li>
<li>Understand which emotional and energetic patterns have kept you from meeting &quot;the one&quot;;</li>
<li>Recognize why the same relationship cycles keep repeating and learn how to break them;</li>
<li>Save hundreds of dollars on shallow compatibility tests and guesswork this is a deep, personal exploration of your love energy;</li>
<li>You will receive not only your personalized portrait but also powerful insights into your emotional energy who truly nourishes it, who drains it, and how these invisible exchanges shape your relationships.</li>
</ul>
</div>
{/* Третий текстовый блок */}
<p className="text-[#2C2C2C] text-center font-sans text-[15px] font-medium leading-[125%] mt-[14px]">
Creating your portrait and guide costs us $13.76, but you can choose the amount that feels right to you.
</p>
{/* TrialOptionsGrid с ref для прокрутки */}
<div ref={trialOptionsRef} className="mt-[14px]">
<TrialOptionsGrid
className="w-full"
items={items}
onItemClick={(id) => {
setSelectedId(id);
setSelectedVariantId(id); // Save to context for Payment screen
if (showError) setShowError(false);
}}
/>
</div>
{/* Блок со стрелочкой и текстом под вариантами */}
<div className="flex flex-col gap-[9px] items-end w-full mt-[14px]">
{/* Контейнер стрелочки */}
<div className="relative h-[22px] w-full">
{/* Стрелочка указывает на центр правой колонки (последний вариант) */}
<div
className="absolute top-1/2"
style={{
right: '25%', // Правая колонка = 50% ширины, ее центр = 25% от правого края
transform: 'translateY(-50%) translateX(50%)' // Центрируем саму стрелочку
}}
>
<svg
width="15"
height="22"
viewBox="0 0 15 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_570_2828)">
<path
d="M8.07009 0.292893C7.67956 -0.097631 7.0464 -0.097631 6.65587 0.292893L0.291916 6.65685C-0.0986075 7.04738 -0.0986075 7.68054 0.291916 8.07107C0.682441 8.46159 1.3156 8.46159 1.70613 8.07107L7.36298 2.41421L13.0198 8.07107C13.4104 8.46159 14.0435 8.46159 14.434 8.07107C14.8246 7.68054 14.8246 7.04738 14.434 6.65685L8.07009 0.292893ZM7.36298 22H8.36298V1H7.36298H6.36298V22H7.36298Z"
fill="#224E90"
/>
</g>
<defs>
<clipPath id="clip0_570_2828">
<rect width="15" height="22" fill="white"/>
</clipPath>
</defs>
</svg>
</div>
</div>
{/* Текст под стрелочкой */}
<p className="text-[#0244A5] font-sans text-[15px] font-semibold leading-[125%] w-full">
It costs us $13.76 to compensate our team for the work involved in creating your personalized portrait and guide, but please choose the amount you are comfortable with.
</p>
</div>
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { TrialChoiceTemplate } from "./TrialChoiceTemplate";

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

@ -8,6 +8,7 @@ export { CouponTemplate } from "./CouponTemplate";
export { LoadersTemplate } from "./LoadersTemplate";
export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";
export { TrialPaymentTemplate } from "./TrialPaymentTemplate/index";
export { TrialChoiceTemplate } from "./TrialChoiceTemplate";
export { SpecialOfferTemplate } from "./SpecialOffer/index";
// Layout Templates

View File

@ -0,0 +1,54 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { fn } from "storybook/test";
import { TrialOption } from "./TrialOption";
import type { TrialOptionProps } from "./TrialOption";
/** Reusable TrialOption component matching Figma states */
const meta: Meta<typeof TrialOption> = {
title: "UI/TrialOption",
component: TrialOption,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
value: "$1",
title: "Basic",
state: "default",
error: false,
onClick: fn(),
} satisfies TrialOptionProps,
argTypes: {
state: {
control: { type: "select" },
options: ["default", "selected", "accent"],
},
error: { control: { type: "boolean" } },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Selected: Story = {
args: { state: "selected" },
};
export const Accent: Story = {
args: { state: "accent" },
};
export const ErrorDefault: Story = {
args: { state: "default", error: true },
};
export const ErrorAccent: Story = {
args: { state: "accent", error: true },
};
export const Playground: Story = {
args: {},
};

View File

@ -0,0 +1,89 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export type TrialOptionState = "default" | "selected" | "accent";
export interface TrialOptionProps extends React.ComponentProps<"button"> {
value?: string;
title?: string;
state?: TrialOptionState;
error?: boolean;
}
export function TrialOption({
className,
value = "$1",
title = "Basic",
state = "default",
error = false,
...props
}: TrialOptionProps) {
const isSelected = state === "selected";
const isAccent = state === "accent";
// Error must apply only to default and accent (not to selected)
const isError = error && !isSelected;
return (
<button
type="button"
{...props}
className={cn(
// Layout (flex to allow w-full expansion inside grid cells)
"flex w-full select-none items-center justify-center p-[14px] gap-1 rounded-[12px] transition-all duration-150",
// Focus ring (does not affect layout)
"outline-none focus-visible:ring-[3px] focus-visible:ring-[#3A70B2]/30",
"active:scale-[0.99]",
// Backgrounds
!isSelected && "bg-[#F9FAFB]",
// Shadows
!isSelected
? "shadow-[0_2px_4px_rgba(0,0,0,0.10),_0_4px_6px_rgba(0,0,0,0.10)] hover:shadow-[0_4px_6px_rgba(0,0,0,0.10),_0_8px_12px_rgba(0,0,0,0.10)]"
: "shadow-[0_4px_6px_rgba(0,0,0,0.10),_0_10px_15px_rgba(0,0,0,0.10)] hover:shadow-[0_6px_10px_rgba(0,0,0,0.10),_0_12px_18px_rgba(0,0,0,0.10)]",
// Borders per state
// Keep a constant 2px border to avoid layout shifts
// Default state (2px #D1D5DB) unless error overrides
state === "default" && !isError && "border-[2px] border-[#D1D5DB] hover:border-[#9CA3AF]",
// Accent state: simulate 4px using inset ring (no layout shift, stays inside)
isAccent && !isError && "border-[2px] border-[#9CA3AF] ring-inset ring-[2px] ring-[#9CA3AF] hover:border-[#6B7280] hover:ring-[#6B7280]",
// Selected state (2px #3A70B2)
isSelected && "border-[2px] border-[#3A70B2]",
// Error overrides: default -> 2px red; accent -> 2px red + ring 2px red
isError && !isAccent && "border-[2px] border-[#FF0D11]",
isError && isAccent && "border-[2px] border-[#FF0D11] ring-inset ring-[2px] ring-[#FF0D11]",
className
)}
style={
isSelected
? {
// background: var(--TrialGradient, linear-gradient(90deg, #5393DE 0%, #3A70B2 100%))
background:
"var(--TrialGradient, linear-gradient(90deg, #5393DE 0%, #3A70B2 100%))",
}
: undefined
}
>
<div className="flex flex-col items-center justify-center gap-1">
<p
className={cn(
"font-inter font-bold text-[20px] leading-[28px]",
// Default/Accent normal -> black, Selected -> #E5E7EB, Error -> #FF0D11
isSelected ? "text-[#E5E7EB]" : isError ? "text-[#FF0D11]" : "text-[#000000]"
)}
>
{value}
</p>
<p
className={cn(
"font-inter font-semibold text-[14px] leading-[20px]",
// Default/Accent normal -> #6B7280, Selected -> #E5E7EB, Error -> #FF0D11
isSelected ? "text-[#E5E7EB]" : isError ? "text-[#FF0D11]" : "text-[#6B7280]"
)}
>
{title}
</p>
</div>
</button>
);
}

View File

@ -0,0 +1,2 @@
export { TrialOption } from "./TrialOption";
export type { TrialOptionProps, TrialOptionState } from "./TrialOption";

View File

@ -23,6 +23,8 @@ export interface BottomActionButtonProps extends React.ComponentProps<"div"> {
syncCssVar?: boolean;
gradientBlurProps?: React.ComponentProps<typeof GradientBlur>;
/** Вызывается при клике на отключенную кнопку действия */
onDisabledClick?: () => void;
}
const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
@ -35,6 +37,7 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
className,
syncCssVar = true,
gradientBlurProps,
onDisabledClick,
...props
},
ref
@ -95,7 +98,30 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
{childrenAboveButton}
</div>
)}
{hasButton ? <ActionButton {...actionButtonProps} /> : null}
{hasButton ? (
<div className="relative">
{/* Invisible overlay to capture clicks when button is disabled */}
{actionButtonProps?.disabled && onDisabledClick ? (
<div
className="absolute inset-0 z-10 cursor-pointer"
role="button"
aria-disabled="true"
onClick={(e) => {
e.preventDefault();
onDisabledClick();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onDisabledClick();
}
}}
tabIndex={0}
/>
) : null}
<ActionButton {...actionButtonProps} />
</div>
) : null}
{childrenUnderButton}
</GradientBlur>
</div>

View File

@ -0,0 +1,75 @@
/* eslint-disable storybook/no-renderer-packages */
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="flex items-center gap-5 rounded-2xl border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-[18px] w-full">
{/* Profile section with avatar and email */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* 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 min-w-0 flex-1">
<p className="font-inter text-lg font-semibold leading-7 text-[#333333] truncate w-full">
{email}
</p>
<p className="font-inter text-sm font-medium leading-5 text-blue-600 whitespace-nowrap">
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,73 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { fn } from "storybook/test";
import { TrialOptionsGrid } from "./TrialOptionsGrid";
import type { TrialOptionsItem } from "./TrialOptionsGrid";
const buildItems = (n: number): TrialOptionsItem[] =>
Array.from({ length: n }).map((_, i) => ({
id: `opt-${i + 1}`,
value: `$${i + 1}`,
title: i % 2 === 0 ? "Basic" : "Pro",
state: i === 1 ? "selected" : i === 2 ? "accent" : "default",
error: false,
onClick: fn(),
}));
const meta: Meta<typeof TrialOptionsGrid> = {
title: "Widgets/TrialOptionsGrid",
component: TrialOptionsGrid,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
items: buildItems(2),
onItemClick: fn(),
className: "w-[360px]",
},
};
export default meta;
export type Story = StoryObj<typeof meta>;
export const One: Story = {
args: {
items: buildItems(1),
},
};
export const Two: Story = {
args: {
items: buildItems(2),
},
};
export const Three: Story = {
args: {
items: buildItems(3),
},
};
export const Four: Story = {
args: {
items: buildItems(4),
},
};
export const Five: Story = {
args: {
items: buildItems(5),
},
};
export const WithErrors: Story = {
args: {
items: [
{ id: "a", value: "$1", title: "Basic", state: "default", error: true },
{ id: "b", value: "$2", title: "Pro", state: "selected", error: false },
{ id: "c", value: "$3", title: "Plus", state: "accent", error: true },
{ id: "d", value: "$4", title: "Max", state: "default", error: false },
],
},
};

View File

@ -0,0 +1,67 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { TrialOption } from "@/components/ui/TrialOption";
import type { TrialOptionProps } from "@/components/ui/TrialOption";
export type TrialOptionsItem = (Omit<TrialOptionProps, "children"> & { id: string });
export interface TrialOptionsGridProps extends React.ComponentProps<"div"> {
items: TrialOptionsItem[];
/** Fallback click handler if item doesn't provide onClick */
onItemClick?: (id: string) => void;
/** Extra class applied to each option wrapper */
itemClassName?: string;
}
/**
* TrialOptionsGrid lays out TrialOption components in two columns with 12px gap.
* - 1 item full width
* - 2 items two columns
* - Odd count (>=3) last item spans full width
* - Even count pairs in two columns
*/
export function TrialOptionsGrid({
className,
items,
onItemClick,
itemClassName,
...props
}: TrialOptionsGridProps) {
const count = items.length;
return (
<div
{...props}
className={cn(
// 2-column grid, 12px gap
"grid grid-cols-2 gap-3 w-full",
className
)}
>
{items.map((item, index) => {
const isLast = index === count - 1;
const shouldSpanFull =
count === 1 || (count % 2 !== 0 && isLast);
return (
<div
key={item.id}
className={cn(shouldSpanFull ? "col-span-2" : "col-span-1", itemClassName)}
style={shouldSpanFull ? { gridColumn: "1 / -1" } : undefined}
>
<TrialOption
{...item}
onClick={(e) => {
item.onClick?.(e);
if (!item.onClick && onItemClick) onItemClick(item.id);
}}
className={cn("w-full", item.className)}
/>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,2 @@
export { TrialOptionsGrid } from "./TrialOptionsGrid";
export type { TrialOptionsGridProps, TrialOptionsItem } from "./TrialOptionsGrid";

View File

@ -21,6 +21,8 @@ export const FunnelPaymentVariantSchema = z.object({
price: z.number(),
oldPrice: z.number().optional(),
trialPrice: z.number().optional(),
title: z.string().optional(),
accent: z.boolean().optional(),
});
export const FunnelPaymentPlacementSchema = z.object({

View File

@ -0,0 +1,122 @@
"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
);
type PreloadedPlacement = {
funnelKey: string;
paymentId: string;
placement: IFunnelPaymentPlacement;
};
export function PaymentPlacementProvider({
children,
initialCache,
}: {
children: React.ReactNode;
initialCache?: PreloadedPlacement[];
}) {
// Cache: Map<"funnelKey:paymentId", PlacementCacheEntry>
const [cache, setCache] = useState<Map<string, PlacementCacheEntry>>(() => {
const map = new Map<string, PlacementCacheEntry>();
initialCache?.forEach(({ funnelKey, paymentId, placement }) => {
const key = `${funnelKey}:${paymentId}`;
map.set(key, { placement, isLoading: false, error: null });
});
return 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

@ -8,8 +8,25 @@ export const useClientToken = () => {
useEffect(() => {
(async () => {
const token = await getAuthTokenFromCookie();
setToken(token);
try {
const token = await getAuthTokenFromCookie();
// If server returned undefined, fall back to a stable mock in Storybook
if (!token && typeof window !== "undefined") {
const w = window as Window & { __STORYBOOK_ADDONS?: unknown };
const isStorybook = Boolean(w.__STORYBOOK_ADDONS) ||
/:\/\/.*(:6006|storybook)/i.test(w.location.href);
setToken(isStorybook ? "storybook-token" : undefined);
} else {
setToken(token);
}
} catch {
// In Storybook or non-Next runtime, server action may fail; use mock token
if (typeof window !== "undefined") {
setToken("storybook-token");
} else {
setToken(undefined);
}
}
})();
}, []);

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

View File

@ -31,3 +31,4 @@ export { buildLoadersDefaults } from "./loaders";
export { buildSoulmateDefaults } from "./soulmate";
export { buildTrialPaymentDefaults } from "./trialPayment";
export { buildSpecialOfferDefaults } from "./specialOffer";
export { buildTrialChoiceDefaults } from "./trialChoice";

View File

@ -0,0 +1,30 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
} from "./blocks";
export function buildTrialChoiceDefaults(id: string): BuilderScreen {
return {
id,
template: "trialChoice",
header: buildDefaultHeader({
showProgress: false,
}),
title: buildDefaultTitle({
show: false,
text: "Trial Choice",
}),
subtitle: buildDefaultSubtitle({
show: false,
text: undefined,
}),
bottomActionButton: buildDefaultBottomActionButton({
text: "Next",
}),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -129,6 +129,61 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
},
});
}
case "insert-screen": {
const { atIndex, template = "list" } = action.payload;
const nextId = generateScreenId(state.screens.map((s) => s.id));
const newScreen = createScreenByTemplate(template, nextId);
// Вставляем экран на указанную позицию
const updatedScreens = [...state.screens];
updatedScreens.splice(atIndex, 0, newScreen);
// 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ
// Обновляем навигацию предыдущего экрана (если есть)
if (atIndex > 0) {
const prevScreen = updatedScreens[atIndex - 1];
const nextScreen = updatedScreens[atIndex + 1];
// Если предыдущий экран указывал на следующий, перенаправляем через новый
if (nextScreen && prevScreen.navigation?.defaultNextScreenId === nextScreen.id) {
updatedScreens[atIndex - 1] = {
...prevScreen,
navigation: {
...prevScreen.navigation,
defaultNextScreenId: nextId,
},
};
// Новый экран указывает на следующий
updatedScreens[atIndex] = {
...newScreen,
navigation: {
...newScreen.navigation,
defaultNextScreenId: nextScreen.id,
},
};
} else if (!prevScreen.navigation?.defaultNextScreenId) {
// Если у предыдущего нет перехода, связываем с новым
updatedScreens[atIndex - 1] = {
...prevScreen,
navigation: {
...prevScreen.navigation,
defaultNextScreenId: nextId,
},
};
}
}
return withDirty(state, {
...state,
screens: updatedScreens,
selectedScreenId: newScreen.id,
meta: {
...state.meta,
firstScreenId: atIndex === 0 ? newScreen.id : state.meta.firstScreenId ?? newScreen.id,
},
});
}
case "remove-screen": {
const filtered = state.screens.filter((screen) => screen.id !== action.payload.screenId);
const selectedScreenId =

View File

@ -13,6 +13,7 @@ export type BuilderAction =
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
| { type: "set-default-texts"; payload: Partial<BuilderFunnelState["defaultTexts"]> }
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
| { type: "insert-screen"; payload: { template?: ScreenDefinition["template"]; atIndex: number } & Partial<BuilderScreen> }
| { type: "remove-screen"; payload: { screenId: string } }
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }

View File

@ -11,6 +11,7 @@ import { buildLoadersDefaults } from "./defaults/loaders";
import { buildSoulmateDefaults } from "./defaults/soulmate";
import { buildTrialPaymentDefaults } from "./defaults/trialPayment";
import { buildSpecialOfferDefaults } from "./defaults/specialOffer";
import { buildTrialChoiceDefaults } from "./defaults/trialChoice";
/**
* Marks the state as dirty if it has changed
@ -66,6 +67,8 @@ export function createScreenByTemplate(
return buildTrialPaymentDefaults(id);
case "specialOffer":
return buildSpecialOfferDefaults(id);
case "trialChoice":
return buildTrialChoiceDefaults(id);
default:
throw new Error(`Unknown template: ${template}`);
}

File diff suppressed because it is too large Load Diff

View File

@ -150,6 +150,7 @@ interface BuildActionButtonOptions {
defaultText?: string;
disabled?: boolean;
onClick: () => void;
onDisabledClick?: () => void;
}
export function buildActionButtonProps(
@ -195,6 +196,7 @@ export function buildBottomActionButtonProps(
return {
actionButtonProps,
showGradientBlur: buttonDef?.showGradientBlur ?? true, // Градиент по умолчанию включен
onDisabledClick: options.onDisabledClick,
};
}

View File

@ -13,6 +13,7 @@ import {
SoulmatePortraitTemplate,
TrialPaymentTemplate,
SpecialOfferTemplate,
TrialChoiceTemplate,
} from "@/components/funnel/templates";
import type {
ListScreenDefinition,
@ -29,6 +30,7 @@ import type {
FunnelDefinition,
FunnelAnswers,
SpecialOfferScreenDefinition,
TrialChoiceScreenDefinition,
} from "@/lib/funnel/types";
export interface ScreenRenderProps {
@ -353,6 +355,31 @@ const TEMPLATE_REGISTRY: Record<
/>
);
},
trialChoice: ({
funnel,
screen,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
answers,
}) => {
const trialChoiceScreen = screen as TrialChoiceScreenDefinition;
return (
<TrialChoiceTemplate
funnel={funnel}
screen={trialChoiceScreen}
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
defaultTexts={defaultTexts}
answers={answers}
/>
);
},
};
export function renderScreen(props: ScreenRenderProps): JSX.Element {

View File

@ -24,6 +24,7 @@ export interface ActionButtonConfig {
defaultText: string;
disabled: boolean;
onClick: () => void;
onDisabledClick?: () => void;
}
/**

View File

@ -557,6 +557,17 @@ export interface SpecialOfferScreenDefinition {
variants?: ScreenVariantDefinition<SpecialOfferScreenDefinition>[];
}
export interface TrialChoiceScreenDefinition {
id: string;
template: "trialChoice";
header?: HeaderDefinition;
title: TitleDefinition;
subtitle?: SubtitleDefinition;
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<TrialChoiceScreenDefinition>[];
}
export type ScreenDefinition =
| InfoScreenDefinition
| DateScreenDefinition
@ -567,7 +578,8 @@ export type ScreenDefinition =
| LoadersScreenDefinition
| SoulmatePortraitScreenDefinition
| TrialPaymentScreenDefinition
| SpecialOfferScreenDefinition;
| SpecialOfferScreenDefinition
| TrialChoiceScreenDefinition;
export interface FunnelMetaDefinition {
id: string;

View File

@ -96,6 +96,7 @@ const HeaderDefinitionSchema = new Schema(
className: String,
},
showBackButton: { type: Boolean, default: true },
showProgress: { type: Boolean, default: true },
show: { type: Boolean, default: true },
},
{ _id: false }
@ -184,6 +185,7 @@ const ScreenDefinitionSchema = new Schema(
"soulmate",
"trialPayment",
"specialOffer",
"trialChoice",
],
required: true,
},