commit
1ee0951481
@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
188
docs/PAYMENT_TEMPLATE_VARIABLES.md
Normal file
188
docs/PAYMENT_TEMPLATE_VARIABLES.md
Normal file
@ -0,0 +1,188 @@
|
||||
# Payment Template Variables
|
||||
|
||||
## Доступные переменные для подстановки
|
||||
|
||||
В текстах экранов **TrialPayment** и **SpecialOffer** можно использовать следующие переменные через синтаксис `{{variableName}}`.
|
||||
|
||||
### Основные переменные
|
||||
|
||||
| Переменная | Описание | Пример значения |
|
||||
|------------|----------|-----------------|
|
||||
| `{{trialPrice}}` | Форматированная цена триала | `$1.00`, `€5.00` |
|
||||
| `{{billingPrice}}` | Форматированная цена подписки | `$14.99`, `€49.99` |
|
||||
| `{{trialPeriod}}` | Период триала с интервалом | `7 days`, `1 week`, `2 weeks` |
|
||||
| `{{billingPeriod}}` | Период списания с интервалом | `1 week`, `1 month`, `3 months` |
|
||||
| `{{trialPeriodHyphen}}` | Период триала через дефис | `7-day`, `1-week` |
|
||||
|
||||
### Дополнительные переменные
|
||||
|
||||
| Переменная | Описание | Пример значения | Шаблон |
|
||||
|------------|----------|-----------------|---------|
|
||||
| `{{oldPrice}}` | Старая цена (для скидки) | `$14.99` | TrialPayment |
|
||||
| `{{discountPercent}}` | Процент скидки | `94` | TrialPayment, SpecialOffer |
|
||||
| `{{oldTrialPrice}}` | Старая цена триала | `$14.99` | SpecialOffer |
|
||||
| `{{oldTrialPeriod}}` | Старый период триала | `7 days` | SpecialOffer |
|
||||
|
||||
## Где использовать
|
||||
|
||||
### TrialPayment экран
|
||||
|
||||
Переменные работают во **ВСЕХ текстовых полях** экрана, включая:
|
||||
|
||||
#### `tryForDays.title`
|
||||
```json
|
||||
{
|
||||
"text": "Try it for {{trialPeriod}}!"
|
||||
}
|
||||
```
|
||||
|
||||
#### `tryForDays.textList.items`
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "text": "Start your {{trialPeriodHyphen}} trial for just {{trialPrice}}." },
|
||||
{ "text": "Then only {{billingPrice}}/{{billingPeriod}} for full access." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `totalPrice.priceContainer.price`
|
||||
```json
|
||||
{
|
||||
"text": "{{trialPrice}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `totalPrice.priceContainer.oldPrice`
|
||||
```json
|
||||
{
|
||||
"text": "{{oldPrice}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `totalPrice.priceContainer.discount`
|
||||
```json
|
||||
{
|
||||
"text": "{{discountPercent}}% discount applied"
|
||||
}
|
||||
```
|
||||
|
||||
### SpecialOffer экран
|
||||
|
||||
Переменные работают во всех текстовых полях:
|
||||
|
||||
#### `text.title`
|
||||
```json
|
||||
{
|
||||
"text": "SPECIAL {{discountPercent}}% DISCOUNT"
|
||||
}
|
||||
```
|
||||
|
||||
#### `text.subtitle`
|
||||
```json
|
||||
{
|
||||
"text": "Only {{trialPrice}} for {{trialPeriod}}"
|
||||
}
|
||||
```
|
||||
|
||||
#### `text.description`
|
||||
```json
|
||||
{
|
||||
"trialPrice": { "text": "{{trialPrice}}" },
|
||||
"text": { "text": "for {{trialPeriod}}" },
|
||||
"oldTrialPrice": { "text": "{{oldTrialPrice}}" }
|
||||
}
|
||||
```
|
||||
|
||||
## Policy текст (хардкоженный)
|
||||
|
||||
Policy текст автоматически подставляет значения **без** использования `{{}}`:
|
||||
|
||||
```tsx
|
||||
You also acknowledge that your {trialPeriodHyphen} introductory plan to Wit Lab LLC,
|
||||
billed at {formattedTrialPrice}, will automatically renew at {formattedBillingPrice}
|
||||
every {billingPeriodText} unless canceled before the end of the trial period.
|
||||
```
|
||||
|
||||
**Этот текст НЕ редактируется через админку** - значения подставляются автоматически из выбранного variant.
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Пример 1: Try For Days секция
|
||||
```json
|
||||
{
|
||||
"tryForDays": {
|
||||
"title": {
|
||||
"text": "Try it for {{trialPeriod}}!"
|
||||
},
|
||||
"textList": {
|
||||
"items": [
|
||||
{ "text": "Receive a hand-drawn sketch of your soulmate." },
|
||||
{ "text": "Reveal the path with the guide." },
|
||||
{ "text": "Talk to live experts and get guidance." },
|
||||
{ "text": "Start your {{trialPeriodHyphen}} trial for just {{trialPrice}}." },
|
||||
{ "text": "Then {{billingPrice}} every {{billingPeriod}} for full access." },
|
||||
{ "text": "Cancel anytime—just 24 hours before renewal." }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат** (для variant с trialPrice=100, price=1499, trialInterval=7, billingInterval=1):
|
||||
- "Try it for 7 days!"
|
||||
- "Start your 7-day trial for just $1.00."
|
||||
- "Then $14.99 every 1 week for full access."
|
||||
|
||||
### Пример 2: Total Price секция
|
||||
```json
|
||||
{
|
||||
"totalPrice": {
|
||||
"priceContainer": {
|
||||
"price": { "text": "{{trialPrice}}" },
|
||||
"oldPrice": { "text": "{{oldPrice}}" },
|
||||
"discount": { "text": "{{discountPercent}}% discount applied" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Результат**:
|
||||
- Price: "$1.00"
|
||||
- Old Price: "$14.99"
|
||||
- Discount: "94% discount applied"
|
||||
|
||||
## Форматирование цен
|
||||
|
||||
Цены автоматически форматируются с учетом валюты:
|
||||
- USD: `$1.00`
|
||||
- EUR: `€1.00`
|
||||
- GBP: `£1.00`
|
||||
|
||||
## Форматирование периодов
|
||||
|
||||
Периоды форматируются на английском:
|
||||
- `trialPeriod="DAY"`, `interval=1` → `"1 day"`
|
||||
- `trialPeriod="DAY"`, `interval=7` → `"7 days"`
|
||||
- `trialPeriod="WEEK"`, `interval=1` → `"1 week"`
|
||||
- `trialPeriod="MONTH"`, `interval=3` → `"3 months"`
|
||||
|
||||
С дефисом (`trialPeriodHyphen`):
|
||||
- `"1-day"`, `"7-day"`, `"1-week"`, `"3-month"`
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Регистр имеет значение**: используйте точное написание `{{trialPrice}}`, а не `{{TrialPrice}}`
|
||||
2. **Пробелы не важны**: `{{ trialPrice }}` тоже сработает
|
||||
3. **Несуществующие переменные**: если переменная не найдена, она остается как есть в тексте
|
||||
4. **Пустые значения**: если значение не определено (например, `discountPercent` для variant без скидки), подставляется пустая строка
|
||||
|
||||
## Откуда берутся значения
|
||||
|
||||
Все значения загружаются из **Payment Placement API**:
|
||||
- `GET /api/session/funnel/:funnelId/payment/:paymentId`
|
||||
|
||||
И зависят от **выбранного variant**:
|
||||
- В **Trial Choice** пользователь выбирает variant → его ID сохраняется
|
||||
- В **Trial Payment** используется выбранный variant (или первый по умолчанию)
|
||||
- В **Special Offer** всегда используется первый variant из `main_secret_discount` placement
|
||||
0
docs/TRIAL_CHOICE_PAYMENT_INTEGRATION.md
Normal file
0
docs/TRIAL_CHOICE_PAYMENT_INTEGRATION.md
Normal file
309
package-lock.json
generated
309
package-lock.json
generated
@ -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",
|
||||
|
||||
12
package.json
12
package.json
@ -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",
|
||||
|
||||
2935
public/funnels/soulmate-prod.json
Normal file
2935
public/funnels/soulmate-prod.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,10 @@ import { UnleashProvider } from "@/lib/funnel/unleash";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
import {
|
||||
PaymentPlacementProvider,
|
||||
TrialVariantSelectionProvider,
|
||||
} from "@/entities/session/payment";
|
||||
|
||||
// Функция для загрузки воронки из базы данных
|
||||
async function loadFunnelFromDatabase(
|
||||
@ -73,7 +77,11 @@ export default async function FunnelLayout({
|
||||
googleAnalyticsId={funnel.meta.googleAnalyticsId}
|
||||
yandexMetrikaId={funnel.meta.yandexMetrikaId}
|
||||
>
|
||||
{children}
|
||||
<PaymentPlacementProvider>
|
||||
<TrialVariantSelectionProvider>
|
||||
{children}
|
||||
</TrialVariantSelectionProvider>
|
||||
</PaymentPlacementProvider>
|
||||
</PixelsProvider>
|
||||
</UnleashProvider>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/admin/builder/Canvas/InsertScreenButton.tsx
Normal file
47
src/components/admin/builder/Canvas/InsertScreenButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -13,6 +13,7 @@ export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
|
||||
loaders: "Загрузка",
|
||||
soulmate: "Портрет партнера",
|
||||
trialPayment: "Trial Payment",
|
||||
trialChoice: "Trial Choice",
|
||||
specialOffer: "Special Offer",
|
||||
};
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 = {};
|
||||
@ -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'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 "Finding the One Guide" — 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 "the one";</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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { TrialChoiceTemplate } from "./TrialChoiceTemplate";
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
54
src/components/ui/TrialOption/TrialOption.stories.tsx
Normal file
54
src/components/ui/TrialOption/TrialOption.stories.tsx
Normal 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: {},
|
||||
};
|
||||
89
src/components/ui/TrialOption/TrialOption.tsx
Normal file
89
src/components/ui/TrialOption/TrialOption.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/ui/TrialOption/index.ts
Normal file
2
src/components/ui/TrialOption/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TrialOption } from "./TrialOption";
|
||||
export type { TrialOptionProps, TrialOptionState } from "./TrialOption";
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ProfileCreated } from './ProfileCreated';
|
||||
|
||||
describe('ProfileCreated', () => {
|
||||
it('renders email correctly', () => {
|
||||
render(<ProfileCreated email="test@example.com" />);
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders success message', () => {
|
||||
render(<ProfileCreated email="test@example.com" />);
|
||||
expect(screen.getByText('Profile created successfully')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays first letter of email in uppercase', () => {
|
||||
render(<ProfileCreated email="john@example.com" />);
|
||||
expect(screen.getByText('J')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles lowercase email correctly', () => {
|
||||
render(<ProfileCreated email="alice@example.com" />);
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles uppercase email correctly', () => {
|
||||
render(<ProfileCreated email="BOB@EXAMPLE.COM" />);
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles email starting with number', () => {
|
||||
render(<ProfileCreated email="123user@example.com" />);
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles single character email', () => {
|
||||
render(<ProfileCreated email="x@test.com" />);
|
||||
expect(screen.getByText('X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders checkmark SVG', () => {
|
||||
const { container } = render(<ProfileCreated email="test@example.com" />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg?.getAttribute('width')).toBe('16');
|
||||
expect(svg?.getAttribute('height')).toBe('16');
|
||||
});
|
||||
|
||||
it('has correct structure with all main elements', () => {
|
||||
const { container } = render(<ProfileCreated email="test@example.com" />);
|
||||
|
||||
// Check for avatar container
|
||||
const avatar = container.querySelector('.rounded-full.bg-gradient-to-br');
|
||||
expect(avatar).toBeInTheDocument();
|
||||
|
||||
// Check for email text
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
|
||||
// Check for success text
|
||||
expect(screen.getByText('Profile created successfully')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
57
src/components/widgets/ProfileCreated/ProfileCreated.tsx
Normal file
57
src/components/widgets/ProfileCreated/ProfileCreated.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileCreatedProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileCreated widget displays a success message when a user profile is created.
|
||||
* Shows the email with an avatar containing the first letter of the email.
|
||||
*/
|
||||
export function ProfileCreated({ email }: ProfileCreatedProps) {
|
||||
// Extract first letter of email in uppercase
|
||||
const avatarLetter = email.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="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>
|
||||
);
|
||||
}
|
||||
73
src/components/widgets/ProfileCreated/README.md
Normal file
73
src/components/widgets/ProfileCreated/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# ProfileCreated
|
||||
|
||||
Виджет для отображения успешного создания профиля пользователя.
|
||||
|
||||
## Описание
|
||||
|
||||
`ProfileCreated` - это компонент, который показывает email пользователя с аватаром (первая буква email) и сообщением об успешном создании профиля. Компонент включает зеленую галочку для визуального подтверждения успеха.
|
||||
|
||||
## Использование
|
||||
|
||||
```tsx
|
||||
import { ProfileCreated } from '@/components/widgets/ProfileCreated';
|
||||
|
||||
function MyComponent() {
|
||||
return <ProfileCreated email="user@example.com" />;
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|-------|--------|----------|--------------------------------------|
|
||||
| email | string | Да | Email адрес для отображения |
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Аватар с первой буквой**: Автоматически извлекает первую букву email и преобразует в верхний регистр
|
||||
- **Градиентный фон**: Использует градиент от голубого до индиго
|
||||
- **Градиентная иконка**: Аватар имеет красивый градиент от синего к темно-синему
|
||||
- **Иконка успеха**: Зеленая галочка справа для подтверждения действия
|
||||
- **Адаптивный**: Использует `inline-flex` для гибкого размещения
|
||||
|
||||
## Дизайн
|
||||
|
||||
Компонент точно следует спецификации из Figma:
|
||||
- Padding: 18px
|
||||
- Border: 2px solid #BFDBFE
|
||||
- Border radius: 16px
|
||||
- Avatar size: 48px
|
||||
- Gap между элементами: 20px (основной), 12px (профиль), 3px (внутри профиля)
|
||||
|
||||
## Стили шрифтов
|
||||
|
||||
- **Email**: Inter, 18px, font-weight: 600, color: #333
|
||||
- **Текст успеха**: Inter, 14px, font-weight: 500, color: #2563EB (blue-600)
|
||||
- **Буква в аватаре**: Inter, 20px, font-weight: 700, color: white
|
||||
|
||||
## Storybook
|
||||
|
||||
Для просмотра всех вариантов компонента запустите Storybook:
|
||||
|
||||
```bash
|
||||
npm run storybook
|
||||
```
|
||||
|
||||
Доступные stories:
|
||||
- **Default**: Стандартный вид с примером email
|
||||
- **AnotherEmail**: Пример с другим email
|
||||
- **SingleLetter**: Email начинающийся с одной буквы
|
||||
- **LongEmail**: Длинный email адрес
|
||||
- **UppercaseEmail**: Email в верхнем регистре
|
||||
- **NumberStartEmail**: Email начинающийся с цифры
|
||||
|
||||
## Figma
|
||||
|
||||
Дизайн компонента: [Figma Link](https://www.figma.com/design/kx7k6sswURrLwBeavffF9D/%D0%92%D0%BE%D1%80%D0%BE%D0%BD%D0%BA%D0%B0-%D0%9F%D0%BE%D1%80%D1%82%D1%80%D0%B5%D1%82?node-id=566-2752)
|
||||
|
||||
## Примечания
|
||||
|
||||
- Компонент не имеет внутренних состояний
|
||||
- Не требует интеграции с другими системами
|
||||
- Является чисто презентационным компонентом
|
||||
- Использует только Tailwind CSS для стилизации
|
||||
1
src/components/widgets/ProfileCreated/index.ts
Normal file
1
src/components/widgets/ProfileCreated/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ProfileCreated } from './ProfileCreated';
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
};
|
||||
67
src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx
Normal file
67
src/components/widgets/TrialOptionsGrid/TrialOptionsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/components/widgets/TrialOptionsGrid/index.ts
Normal file
2
src/components/widgets/TrialOptionsGrid/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TrialOptionsGrid } from "./TrialOptionsGrid";
|
||||
export type { TrialOptionsGridProps, TrialOptionsItem } from "./TrialOptionsGrid";
|
||||
@ -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({
|
||||
|
||||
122
src/entities/session/payment/PaymentPlacementProvider.tsx
Normal file
122
src/entities/session/payment/PaymentPlacementProvider.tsx
Normal 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;
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
|
||||
interface TrialVariantSelectionContextValue {
|
||||
selectedVariantId: string | null;
|
||||
setSelectedVariantId: (variantId: string | null) => void;
|
||||
}
|
||||
|
||||
const TrialVariantSelectionContext =
|
||||
createContext<TrialVariantSelectionContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'trial_variant_selection';
|
||||
|
||||
export function TrialVariantSelectionProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Initialize from sessionStorage if available
|
||||
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored || null;
|
||||
});
|
||||
|
||||
const wrappedSetSelectedVariantId = (id: string | null) => {
|
||||
setSelectedVariantId(id);
|
||||
|
||||
// Persist to sessionStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
if (id) {
|
||||
sessionStorage.setItem(STORAGE_KEY, id);
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TrialVariantSelectionContext.Provider
|
||||
value={{ selectedVariantId, setSelectedVariantId: wrappedSetSelectedVariantId }}
|
||||
>
|
||||
{children}
|
||||
</TrialVariantSelectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTrialVariantSelection() {
|
||||
const context = useContext(TrialVariantSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useTrialVariantSelection must be used within TrialVariantSelectionProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
9
src/entities/session/payment/index.ts
Normal file
9
src/entities/session/payment/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export {
|
||||
PaymentPlacementProvider,
|
||||
usePaymentPlacementContext,
|
||||
} from "./PaymentPlacementProvider";
|
||||
|
||||
export {
|
||||
TrialVariantSelectionProvider,
|
||||
useTrialVariantSelection,
|
||||
} from "./TrialVariantSelectionContext";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -31,3 +31,4 @@ export { buildLoadersDefaults } from "./loaders";
|
||||
export { buildSoulmateDefaults } from "./soulmate";
|
||||
export { buildTrialPaymentDefaults } from "./trialPayment";
|
||||
export { buildSpecialOfferDefaults } from "./specialOffer";
|
||||
export { buildTrialChoiceDefaults } from "./trialChoice";
|
||||
|
||||
30
src/lib/admin/builder/state/defaults/trialChoice.ts
Normal file
30
src/lib/admin/builder/state/defaults/trialChoice.ts
Normal 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;
|
||||
}
|
||||
@ -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 =
|
||||
|
||||
@ -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 } }
|
||||
|
||||
@ -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
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -24,6 +24,7 @@ export interface ActionButtonConfig {
|
||||
defaultText: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
onDisabledClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user