Compare commits
11
Commits
4792df274e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
981ea3a893 | ||
|
|
62d76916ca | ||
|
|
ba6da38b9c | ||
|
|
7a231cf92f | ||
|
|
197bbb80ec | ||
|
|
09b069cbb5 | ||
|
|
74d51f72bf | ||
|
|
42518b2137 | ||
|
|
767a0d5a6e | ||
|
|
651a115ab7 | ||
|
|
d8777a7a23 |
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: pick-ui-library
|
||||
description: Pick the right library for a given frontend task from a curated, opinionated list — numbers, OTP inputs, charts, command menus, virtualization, drag and drop, toasts, state, styling, and more. Only runs when explicitly invoked; it does not trigger on its own.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Picking The Right Library
|
||||
|
||||
## Initial Response
|
||||
|
||||
When this skill is first invoked without a specific question, respond only with:
|
||||
|
||||
> I'm ready to pick the right library for your task, my picks come from Emil Kowalski's curated list.
|
||||
|
||||
Do not provide any other information until the user asks a question.
|
||||
|
||||
A lookup skill. When invoked with a task ("I need toasts", "what should I use for drag and drop?"), match the task to the curated list below and recommend the library. These are deliberate, taste-driven picks — don't substitute alternatives outside this list unless the user asks for one or the task genuinely isn't covered.
|
||||
|
||||
## How to use this
|
||||
|
||||
1. **Identify the task**, not the library the user named. "I need to show a dropdown" is a UI-primitives task (base-ui), even if they asked about something else.
|
||||
2. **Check what's already installed.** Look at `package.json` first. If the project already uses a listed library, use it. If it uses a competitor (e.g. react-window instead of Virtuoso), flag the recommendation but don't churn the dependency without being asked.
|
||||
3. **Recommend one library**, state what it's for in one sentence, and install/wire it up if that's part of the request. Don't present a menu of options when the list has a clear answer.
|
||||
4. If the task isn't covered by the list, say so explicitly and recommend from your own knowledge — but be clear you've left the curated list.
|
||||
|
||||
## The list
|
||||
|
||||
### UI components & primitives
|
||||
|
||||
| Task | Library |
|
||||
| --- | --- |
|
||||
| Unstyled, accessible UI components (dialogs, popovers, menus, selects…) | [base-ui](https://base-ui.com) |
|
||||
| Command menus (⌘K palettes) | [cmdk](https://cmdk.paco.me) |
|
||||
| Toasts / notifications | [Sonner](https://sonner.emilkowal.ski) |
|
||||
| One-time password / verification code inputs | [input-otp](https://input-otp.rodz.dev) |
|
||||
| Customizable GUIs / control panels | [Leva](https://github.com/pmndrs/leva) — [dialkit](https://joshpuckett.me/dialkit) is an alternative |
|
||||
|
||||
### Motion & visuals
|
||||
|
||||
| Task | Library |
|
||||
| --- | --- |
|
||||
| General-purpose animation (springs, layout animations, enter/exit) | [motion](https://motion.dev) (Framer Motion) |
|
||||
| Animating numbers (counters, prices, stats) | [NumberFlow](https://number-flow.barvian.me) |
|
||||
| Animated text components | [torph](https://torph.lochie.me/) |
|
||||
| 3D globes | [Cobe](https://cobe.vercel.app) |
|
||||
| Dynamic OG images (HTML/CSS → SVG/PNG) | [Satori](https://github.com/vercel/satori) |
|
||||
| Syntax highlighting | [shiki](https://shiki.style) |
|
||||
|
||||
Reach for motion when you need springs, layout animations, exit animations, or gesture-driven values. A simple hover or fade doesn't need it — plain CSS transitions are the right tool there.
|
||||
|
||||
### Charts
|
||||
|
||||
| Task | Library |
|
||||
| --- | --- |
|
||||
| Real-time / streaming charts | [Liveline](https://github.com/benjitaylor/liveline) |
|
||||
| General charts (static or interactive dashboards) | [recharts](https://recharts.org) |
|
||||
|
||||
The split: if data points arrive live and the chart scrolls with time, use Liveline. Everything else is recharts.
|
||||
|
||||
### Interaction & performance
|
||||
|
||||
| Task | Library |
|
||||
| --- | --- |
|
||||
| Drag and drop | [dnd kit](https://dndkit.com) |
|
||||
| Virtualization (long lists, large tables) | [Virtuoso](https://virtuoso.dev) |
|
||||
|
||||
### State & styling
|
||||
|
||||
| Task | Library |
|
||||
| --- | --- |
|
||||
| State management | [zustand](https://zustand.docs.pmnd.rs) |
|
||||
| Constructing `className` strings conditionally | [clsx](https://github.com/lukeed/clsx) |
|
||||
| Type-safe, variant-driven styling for Tailwind | [cva](https://cva.style) |
|
||||
| Theme switching / dark mode (no flash on load) | [next-themes](https://github.com/pacocoursey/next-themes) |
|
||||
|
||||
The styling split: clsx for ad-hoc conditional classes; cva when a component has real variants (size, intent, state) that deserve a typed API. They compose — cva uses clsx-style inputs internally.
|
||||
|
||||
## Common mismatches to catch
|
||||
|
||||
- **Toasts built by hand or with a modal library** → Sonner exists for exactly this.
|
||||
- **A `<div>`-based dropdown/dialog with manual focus handling** → base-ui, which handles accessibility, focus trapping, and dismissal.
|
||||
- **Animating a number by re-rendering text** → NumberFlow handles digit transitions properly.
|
||||
- **Rendering a 1,000+ row list directly** → Virtuoso before reaching for pagination hacks.
|
||||
- **A `useState`-per-component web of props for shared state** → zustand.
|
||||
- **Template-literal className ternaries three conditions deep** → clsx (or cva if it's variant-shaped).
|
||||
|
||||
## Project constraints (Second Brain LMS — override the list above)
|
||||
|
||||
Source: emilkowalski/skills @ 715c180 (MIT). Section added for this repo on 20261111.
|
||||
|
||||
- UI primitives: this project uses shadcn (style "base-nova") on top of @base-ui/react. For dialogs, menus, selects and popovers run `npx shadcn add <component>` and restyle it to DS-2; do not import raw base-ui parts in feature code. Never add Radix directly.
|
||||
- Design system DS-2 Aubade (SecondBrainTech/02-Стандарты/Дизайн-LMS/DESIGN.md) wins over any library default: radius 2px, 2px borders, Fira Mono, hard offset shadows (4px 4px 0, no blur), no gradients, one accent #E8F0D8, red only for errors.
|
||||
- Motion: DS-2 hover/active physics (translate ±2px, 0.1s ease) and plain CSS transitions. At most ONE prominent animated element per page (an animated number, hero or background). Under prefers-reduced-motion there is no motion at all. Already installed for this: @number-flow/react (course progress percent) and vaul (mobile drawer). Do NOT add motion, torph, Cobe or other animation libraries without the owner's explicit approval.
|
||||
- Never install a new dependency without asking first: name the package, its size and why the existing stack can't do it.
|
||||
- Already installed and preferred: sonner (src/components/ui/sonner.tsx), cmdk (src/components/admin/admin-command-menu.tsx), vaul via src/components/shared/responsive-dialog.tsx, @number-flow/react, @dnd-kit, clsx, class-variance-authority, next-themes, lucide-react, tailwind-merge.
|
||||
@@ -21,6 +21,12 @@ TURNSTILE_SECRET_KEY=""
|
||||
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
|
||||
NEXT_PUBLIC_METRIKA_COUNTER_ID=""
|
||||
|
||||
# Адрес payment-router для чекаута витрины (/pay/order, /pay/methods). Build-time.
|
||||
# Пусто: dev → локальная заглушка /dev/pay-stub, production-сборка → боевой
|
||||
# https://obsidian.second-brain.ru. Для локальной `next build && next start`
|
||||
# задать явно (например http://localhost:9), иначе форма уйдёт на прод.
|
||||
NEXT_PUBLIC_PAY_BASE_URL=""
|
||||
|
||||
# Kinescope API
|
||||
KINESCOPE_API_KEY=""
|
||||
|
||||
@@ -37,6 +43,11 @@ LISTMONK_URL="https://newsletter.second-brain.ru"
|
||||
LISTMONK_USER="apiuser"
|
||||
LISTMONK_TOKEN=""
|
||||
LISTMONK_QUIZ_LIST_ID=""
|
||||
# Квиз v2: письмо-результат через LMS (tx-шаблон с ветками по quiz_type); пусто — LMS писем не шлёт
|
||||
LISTMONK_QUIZ_TX_TEMPLATE_ID=""
|
||||
QUIZ_RESULT_FROM="Second Brain <delivery@mailsend.second-brain.ru>"
|
||||
# Защита от ботов: потолок лидов квиза в час (по умолчанию 60)
|
||||
QUIZ_LEAD_MAX_PER_HOUR=""
|
||||
|
||||
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
|
||||
# показываем лестницу-оффер (трипвайр + полный курс)
|
||||
|
||||
@@ -220,6 +220,7 @@ S3_REGION="eu-central"
|
||||
|
||||
### Env
|
||||
|
||||
- **Чекаут витрины ходит в боевой payment-router из любой production-сборки.** Адрес `/pay/order` и `/pay/methods` выбирает `resolvePayBase` (`src/lib/pay-base.ts`): `NEXT_PUBLIC_PAY_BASE_URL`, иначе при `NODE_ENV=production` — `https://obsidian.second-brain.ru`, иначе — локальная заглушка `/dev/pay-stub` (в проде 404, ничего не сохраняет и не шлёт). `next dev` безопасен, а **локальная `next build && next start` без `NEXT_PUBLIC_PAY_BASE_URL` отправит форму на прод**: реальный заказ, pending в леджере, Telegram владельцу (случай 20261111, заказ 1794380127). Для такой сборки задавать переменную явно, например `http://localhost:9`. Браузерные проверки — только с сетевой блокировкой внешних доменов и без отправки форм.
|
||||
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
|
||||
|
||||
### Тариф — это отдельный курс со своей копией уроков
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
DB_PASSWORD=lms_cd5041e961a3050db359aa15
|
||||
BETTER_AUTH_SECRET=<secret>
|
||||
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
|
||||
RESEND_API_KEY=re_…(ключ в .env, не в документации)
|
||||
EMAIL_FROM=noreply@mailsend.second-brain.ru
|
||||
S3_ENDPOINT=https://nbg1.your-objectstorage.com
|
||||
S3_BUCKET=second-brain-lms
|
||||
|
||||
Generated
+784
-3
@@ -15,6 +15,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||
"@number-flow/react": "^0.6.2",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"@tiptap/extension-image": "^3.22.2",
|
||||
@@ -28,6 +29,7 @@
|
||||
"better-auth": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"defuddle": "^0.19.1",
|
||||
"disposable-email-domains": "^1.0.62",
|
||||
"force-graph": "^1.51.4",
|
||||
@@ -49,6 +51,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unified": "^11.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2822,6 +2825,20 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@number-flow/react": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.2.tgz",
|
||||
"integrity": "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env": "^1.1.4",
|
||||
"number-flow": "0.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
@@ -3096,7 +3113,557 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
|
||||
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
|
||||
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.3.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.2.1",
|
||||
"@radix-ui/react-focus-guards": "1.2.0",
|
||||
"@radix-ui/react-focus-scope": "1.3.1",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.2.0",
|
||||
"@radix-ui/react-presence": "1.1.11",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.3.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
|
||||
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
|
||||
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
|
||||
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
|
||||
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
|
||||
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
|
||||
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
|
||||
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
|
||||
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -3112,7 +3679,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
@@ -3136,7 +3702,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
@@ -3177,6 +3742,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
|
||||
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref/node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
@@ -6051,6 +6649,18 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
@@ -6831,6 +7441,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -7424,6 +8050,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
@@ -8176,6 +8808,12 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esm-env": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@@ -8706,6 +9344,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-port-please": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
|
||||
@@ -11574,6 +12221,15 @@
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/number-flow": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.2.tgz",
|
||||
"integrity": "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||
@@ -12586,6 +13242,75 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -14243,6 +14968,49 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
@@ -14287,6 +15055,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||
"@number-flow/react": "^0.6.2",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"@tiptap/extension-image": "^3.22.2",
|
||||
@@ -34,6 +35,7 @@
|
||||
"better-auth": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"defuddle": "^0.19.1",
|
||||
"disposable-email-domains": "^1.0.62",
|
||||
"force-graph": "^1.51.4",
|
||||
@@ -55,6 +57,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unified": "^11.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { RegisterForm } from "./register-form";
|
||||
import { UTM_COOKIE, parseUtmCookie, pickUtm } from "@/lib/utm-cookie";
|
||||
|
||||
export default async function RegisterPage({
|
||||
searchParams,
|
||||
@@ -15,6 +17,11 @@ export default async function RegisterPage({
|
||||
|
||||
const sp = await searchParams;
|
||||
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
|
||||
// Метки: из адреса, а если их нет — из cookie лендинга (src/lib/utm-cookie.ts).
|
||||
const utm = pickUtm(
|
||||
{ utmSource: str(sp.utm_source), utmMedium: str(sp.utm_medium), utmCampaign: str(sp.utm_campaign) },
|
||||
parseUtmCookie((await cookies()).get(UTM_COOKIE)?.value),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||
@@ -34,9 +41,9 @@ export default async function RegisterPage({
|
||||
termsUrl={settings.termsUrl}
|
||||
offerUrl={settings.offerUrl}
|
||||
archetype={str(sp.archetype)}
|
||||
utmSource={str(sp.utm_source)}
|
||||
utmMedium={str(sp.utm_medium)}
|
||||
utmCampaign={str(sp.utm_campaign)}
|
||||
utmSource={utm.utmSource}
|
||||
utmMedium={utm.utmMedium}
|
||||
utmCampaign={utm.utmCampaign}
|
||||
gclid={str(sp.gclid)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function StudentDashboard() {
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { archetype: true },
|
||||
select: { archetype: true, phone: true },
|
||||
});
|
||||
|
||||
const enrollments = await prisma.courseEnrollment.findMany({
|
||||
@@ -168,7 +168,7 @@ export default async function StudentDashboard() {
|
||||
{locked.length > 0 && (
|
||||
<StoreShowcase
|
||||
courses={locked}
|
||||
buyer={{ name: session.user.name, email: session.user.email }}
|
||||
buyer={{ name: session.user.name, email: session.user.email, phone: dbUser?.phone }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Read-only lookup for the admin command menu (Ctrl/⌘+K).
|
||||
const MIN_QUERY = 2;
|
||||
const MAX_QUERY = 100;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") {
|
||||
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
|
||||
}
|
||||
|
||||
const q = (request.nextUrl.searchParams.get("q") ?? "").trim().slice(0, MAX_QUERY);
|
||||
if (q.length < MIN_QUERY) {
|
||||
return NextResponse.json({ users: [], courses: [], lessons: [] });
|
||||
}
|
||||
|
||||
const contains = { contains: q, mode: "insensitive" as const };
|
||||
|
||||
const [users, courses, lessons] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { OR: [{ name: contains }, { email: contains }] },
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
}),
|
||||
prisma.course.findMany({
|
||||
where: { OR: [{ title: contains }, { slug: contains }] },
|
||||
select: { id: true, title: true, slug: true, published: true },
|
||||
orderBy: { order: "asc" },
|
||||
take: 6,
|
||||
}),
|
||||
prisma.lesson.findMany({
|
||||
where: { title: contains },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
published: true,
|
||||
module: { select: { id: true, courseId: true, course: { select: { title: true } } } },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 8,
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
courses,
|
||||
lessons: lessons.map((l) => ({
|
||||
id: l.id,
|
||||
title: l.title,
|
||||
published: l.published,
|
||||
moduleId: l.module.id,
|
||||
courseId: l.module.courseId,
|
||||
courseTitle: l.module.course.title,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -1,59 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthorized } from "@/lib/access-check";
|
||||
import {
|
||||
SlidingWindowLimiter,
|
||||
botReason,
|
||||
emailQuery,
|
||||
mergeAttribs,
|
||||
parseQuizLead,
|
||||
planExisting,
|
||||
shouldSendResult,
|
||||
type ListmonkSubscriber,
|
||||
type QuizLead,
|
||||
} from "@/lib/quiz-lead";
|
||||
|
||||
const ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
|
||||
type Archetype = (typeof ARCHETYPES)[number];
|
||||
/**
|
||||
* POST /api/internal/quiz-lead — лид из квиза Typebot (вызов сервер-сервер).
|
||||
*
|
||||
* Подписчик попадает в список квиза, квизовые атрибуты дописываются к
|
||||
* имеющимся — чужие списки, статусы подписок и атрибуты не трогаются
|
||||
* (логика и обоснование — `src/lib/quiz-lead.ts`).
|
||||
*
|
||||
* Защита от ботов: секрет `x-quiz-secret`; квиз быстрее 20 с — молча
|
||||
* пропускаем; общий лимит лидов в час и лимит на адрес; заблокированный в
|
||||
* Listmonk адрес не трогаем; письмо-результат — не чаще раза в сутки на адрес.
|
||||
* IP человека сюда не доходит (вызывает сервер Typebot), поэтому лимиты — не по IP.
|
||||
*/
|
||||
|
||||
interface ListmonkSubscriber {
|
||||
id: number;
|
||||
const MAX_PER_HOUR = Number(process.env.QUIZ_LEAD_MAX_PER_HOUR) || 60;
|
||||
const globalLimiter = new SlidingWindowLimiter(MAX_PER_HOUR, 60 * 60 * 1000);
|
||||
const emailLimiter = new SlidingWindowLimiter(3, 60 * 60 * 1000);
|
||||
|
||||
function auth(): string {
|
||||
return "Basic " + Buffer.from(`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`).toString("base64");
|
||||
}
|
||||
|
||||
interface ListmonkSearchResult {
|
||||
data?: {
|
||||
results?: ListmonkSubscriber[];
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuth(): string {
|
||||
return (
|
||||
"Basic " +
|
||||
Buffer.from(
|
||||
`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`,
|
||||
).toString("base64")
|
||||
);
|
||||
}
|
||||
|
||||
function listmonkUrl(path: string): string {
|
||||
function lm(path: string): string {
|
||||
return `${process.env.LISTMONK_URL}${path}`;
|
||||
}
|
||||
|
||||
function getListId(): number {
|
||||
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
|
||||
async function lmJson(path: string, method: string, body?: unknown): Promise<Response> {
|
||||
return fetch(lm(path), {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", Authorization: auth() },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function isValidEmail(email: string): boolean {
|
||||
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
|
||||
async function findSubscriber(email: string): Promise<ListmonkSubscriber | null> {
|
||||
const res = await fetch(lm("/api/subscribers?per_page=1&query=") + encodeURIComponent(emailQuery(email)), {
|
||||
headers: { Authorization: auth() },
|
||||
});
|
||||
if (!res.ok) throw new Error(`lookup ${res.status}`);
|
||||
const json = (await res.json()) as { data?: { results?: ListmonkSubscriber[] } };
|
||||
return json?.data?.results?.[0] ?? null;
|
||||
}
|
||||
|
||||
function isValidArchetype(value: string): value is Archetype {
|
||||
return (ARCHETYPES as readonly string[]).includes(value);
|
||||
async function sendResult(lead: QuizLead): Promise<void> {
|
||||
const templateId = Number(process.env.LISTMONK_QUIZ_TX_TEMPLATE_ID);
|
||||
if (!templateId) {
|
||||
console.error("quiz-lead: send_result requested, but LISTMONK_QUIZ_TX_TEMPLATE_ID is not set");
|
||||
return;
|
||||
}
|
||||
const res = await lmJson("/api/tx", "POST", {
|
||||
subscriber_email: lead.email,
|
||||
template_id: templateId,
|
||||
from_email: process.env.QUIZ_RESULT_FROM || "Second Brain <delivery@mailsend.second-brain.ru>",
|
||||
data: { quiz_type: lead.quizType, utm: lead.utm },
|
||||
});
|
||||
if (!res.ok) throw new Error(`tx ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/internal/quiz-lead
|
||||
*
|
||||
* Receives a completed Typebot quiz lead and idempotently upserts
|
||||
* the subscriber in Listmonk with archetype + score attributes.
|
||||
*
|
||||
* Auth: x-quiz-secret header must match QUIZ_LEAD_SECRET env var.
|
||||
*/
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
// --- Auth ---
|
||||
const secret = req.headers.get("x-quiz-secret");
|
||||
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
|
||||
if (!isAuthorized(req.headers.get("x-quiz-secret"), process.env.QUIZ_LEAD_SECRET)) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// --- Parse body ---
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
@@ -61,87 +81,76 @@ export async function POST(req: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||
}
|
||||
|
||||
// --- Validate fields ---
|
||||
const raw = body as Record<string, unknown>;
|
||||
const email = String(raw.email ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const archetype = String(raw.archetype ?? "");
|
||||
const score = Number(raw.score);
|
||||
const lead = parseQuizLead(body);
|
||||
if (!lead) return NextResponse.json({ error: "invalid fields" }, { status: 400 });
|
||||
|
||||
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) {
|
||||
return NextResponse.json({ error: "invalid fields" }, { status: 400 });
|
||||
const now = new Date();
|
||||
const bot = botReason(lead, now.getTime());
|
||||
if (bot) {
|
||||
console.warn("quiz-lead: skipped as bot", bot);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (!emailLimiter.allow(lead.email, now.getTime()) || !globalLimiter.allow("all", now.getTime())) {
|
||||
console.warn("quiz-lead: rate limited");
|
||||
return NextResponse.json({ error: "too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const attribs = {
|
||||
archetype,
|
||||
score,
|
||||
utm_source: raw.utm_source != null ? String(raw.utm_source) : null,
|
||||
utm_medium: raw.utm_medium != null ? String(raw.utm_medium) : null,
|
||||
utm_campaign: raw.utm_campaign != null ? String(raw.utm_campaign) : null,
|
||||
};
|
||||
const listId = Number(process.env.LISTMONK_QUIZ_LIST_ID);
|
||||
if (!listId) {
|
||||
console.error("quiz-lead: LISTMONK_QUIZ_LIST_ID is not set");
|
||||
return NextResponse.json({ error: "not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const auth = buildAuth();
|
||||
const listId = getListId();
|
||||
|
||||
// --- Upsert to Listmonk ---
|
||||
try {
|
||||
const createRes = await fetch(listmonkUrl("/api/subscribers"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
lists: [listId],
|
||||
attribs,
|
||||
const sendNow = shouldSendResult(lead, null, now);
|
||||
const createRes = await lmJson("/api/subscribers", "POST", {
|
||||
email: lead.email,
|
||||
name: lead.email.split("@")[0],
|
||||
status: "enabled",
|
||||
lists: [listId],
|
||||
// Новый подписчик — только в одном списке квиза, подтверждать больше нечего.
|
||||
preconfirm_subscriptions: true,
|
||||
}),
|
||||
attribs: mergeAttribs({}, lead, now, sendNow),
|
||||
});
|
||||
|
||||
if (createRes.status === 409) {
|
||||
// Subscriber exists — look up by email and update
|
||||
const searchRes = await fetch(
|
||||
listmonkUrl("/api/subscribers?query=") +
|
||||
encodeURIComponent(`subscribers.email='${email}'`),
|
||||
{ headers: { Authorization: auth } },
|
||||
);
|
||||
const searchJson = (await searchRes.json()) as ListmonkSearchResult;
|
||||
const existingId = searchJson?.data?.results?.[0]?.id;
|
||||
if (createRes.ok) {
|
||||
if (sendNow) await sendResult(lead);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (createRes.status !== 409) {
|
||||
console.error("quiz-lead: listmonk create failed", createRes.status, (await createRes.text()).slice(0, 300));
|
||||
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!existingId) {
|
||||
console.error("quiz-lead: subscriber lookup failed for", email);
|
||||
const existing = await findSubscriber(lead.email);
|
||||
if (!existing) {
|
||||
console.error("quiz-lead: 409, but subscriber not found");
|
||||
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
name: email.split("@")[0],
|
||||
lists: [listId],
|
||||
attribs,
|
||||
status: "enabled",
|
||||
}),
|
||||
});
|
||||
const plan = planExisting(existing, lead, listId, now);
|
||||
if (plan.kind === "skip") return NextResponse.json({ ok: true });
|
||||
|
||||
if (!updateRes.ok) {
|
||||
console.error(
|
||||
"quiz-lead: subscriber update failed",
|
||||
updateRes.status,
|
||||
await updateRes.text(),
|
||||
);
|
||||
if (plan.addToList) {
|
||||
const addRes = await lmJson("/api/subscribers/lists", "PUT", {
|
||||
ids: [existing.id],
|
||||
action: "add",
|
||||
target_list_ids: [listId],
|
||||
status: "confirmed",
|
||||
});
|
||||
if (!addRes.ok) {
|
||||
console.error("quiz-lead: add to list failed", addRes.status, (await addRes.text()).slice(0, 300));
|
||||
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||
}
|
||||
} else if (!createRes.ok) {
|
||||
console.error(
|
||||
"quiz-lead: listmonk create failed",
|
||||
createRes.status,
|
||||
await createRes.text(),
|
||||
);
|
||||
}
|
||||
|
||||
const putRes = await lmJson(`/api/subscribers/${existing.id}`, "PUT", plan.put);
|
||||
if (!putRes.ok) {
|
||||
console.error("quiz-lead: attribs update failed", putRes.status, (await putRes.text()).slice(0, 300));
|
||||
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (plan.sendResult) await sendResult(lead);
|
||||
} catch (e) {
|
||||
console.error("quiz-lead: upstream error", e);
|
||||
return NextResponse.json({ error: "upstream" }, { status: 502 });
|
||||
|
||||
@@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) {
|
||||
{new Date(fb.createdAt).toLocaleString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
|
||||
<p className="text-sm whitespace-pre-wrap mb-2">{linkify(fb.text)}</p>
|
||||
{fbFiles.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{fbFiles.map((f) => (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Local stand-in for payment-router GET /pay/methods (see src/lib/pay-base.ts).
|
||||
// Production: 404. No outbound calls.
|
||||
export function GET() {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
methods: [
|
||||
{ id: "stub-card", label: "Заглушка: карта", provider: "dev-stub", kind: "online" },
|
||||
{ id: "stub-invoice", label: "Заглушка: счёт", provider: "dev-stub", kind: "manual" },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Local stand-in for payment-router POST /pay/order (see src/lib/pay-base.ts).
|
||||
// Echoes the submitted fields back as a page. Stores nothing, sends nothing.
|
||||
// Production: 404.
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
const form = await request.formData();
|
||||
const rows = [...form.entries()]
|
||||
.map(([k, v]) => `<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(typeof v === "string" ? v : "[файл]")}</td></tr>`)
|
||||
.join("");
|
||||
const html = `<!doctype html><meta charset="utf-8"><title>Заглушка оплаты</title>
|
||||
<body style="font-family:'Fira Mono',ui-monospace,monospace;background:#F5F5F0;color:#323232;padding:24px">
|
||||
<h1 style="font-size:20px">Заглушка оплаты (dev)</h1>
|
||||
<p>Заказ не создан, данные никуда не отправлены. Так выглядела бы отправка на payment-router:</p>
|
||||
<table style="border:2px solid #323232;border-collapse:collapse" cellpadding="6">${rows}</table>
|
||||
<p><a href="/dashboard">← На дашборд</a></p></body>`;
|
||||
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
@@ -298,3 +298,14 @@
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.comment-md pre code { background: none; padding: 0; }
|
||||
|
||||
/* DS-2 §8: with prefers-reduced-motion there is no motion at all.
|
||||
Vaul animates the sheet via injected CSS and inline transitions while
|
||||
dragging — !important overrides both, the sheet just appears/disappears. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-vaul-drawer],
|
||||
[data-vaul-overlay] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Command } from "cmdk";
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
import { Search } from "lucide-react";
|
||||
import { adminLinks } from "@/components/admin/admin-nav";
|
||||
|
||||
interface SearchResults {
|
||||
users: { id: string; name: string; email: string; role: string }[];
|
||||
courses: { id: string; title: string; slug: string; published: boolean }[];
|
||||
lessons: {
|
||||
id: string;
|
||||
title: string;
|
||||
published: boolean;
|
||||
moduleId: string;
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const EMPTY: SearchResults = { users: [], courses: [], lessons: [] };
|
||||
const MIN_QUERY = 2;
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
student: "студент",
|
||||
curator: "куратор",
|
||||
admin: "админ",
|
||||
};
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
function useShortcutLabel() {
|
||||
return useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
() => (/Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K"),
|
||||
() => "Ctrl K",
|
||||
);
|
||||
}
|
||||
|
||||
// DS-2: hard-edged group headings, accent highlight for the active row.
|
||||
const groupClass =
|
||||
"mb-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:pb-1 " +
|
||||
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:uppercase " +
|
||||
"[&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group-heading]]:text-[var(--muted-foreground)]";
|
||||
const itemClass =
|
||||
"flex min-h-[44px] cursor-pointer items-center gap-3 border-2 border-transparent px-2 py-1.5 text-sm " +
|
||||
"data-[selected=true]:border-[var(--foreground)] data-[selected=true]:bg-[var(--accent)]";
|
||||
|
||||
export function AdminCommandMenu({ onOpen }: { onOpen?: () => void }) {
|
||||
const router = useRouter();
|
||||
const shortcut = useShortcutLabel();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults>(EMPTY);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Global hotkey: Ctrl+K / ⌘K toggles the menu from anywhere in the admin.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Debounced server lookup; stale responses are aborted.
|
||||
const trimmed = query.trim();
|
||||
useEffect(() => {
|
||||
if (trimmed.length < MIN_QUERY) return;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/search?q=${encodeURIComponent(trimmed)}`, { signal: controller.signal })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
|
||||
.then((data: SearchResults) => setResults(data))
|
||||
.catch((e: unknown) => {
|
||||
if ((e as Error).name !== "AbortError") {
|
||||
console.error("admin search failed", e);
|
||||
setResults(EMPTY);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [trimmed]);
|
||||
|
||||
const active = trimmed.length >= MIN_QUERY;
|
||||
const shown = active ? results : EMPTY;
|
||||
const showLoading = active && loading;
|
||||
const needle = trimmed.toLowerCase();
|
||||
const sections = needle
|
||||
? adminLinks.filter((l) => l.label.toLowerCase().includes(needle))
|
||||
: adminLinks;
|
||||
|
||||
function go(href: string) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setResults(EMPTY);
|
||||
router.push(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpen?.();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-[var(--sidebar-surface)] hover:text-[#F5F5F0]"
|
||||
style={{ border: "2px solid var(--sidebar-border)", color: "var(--sidebar-text)" }}
|
||||
aria-label="Поиск по админке"
|
||||
>
|
||||
<Search size={14} aria-hidden />
|
||||
<span className="flex-1 text-left">Поиск</span>
|
||||
<kbd className="text-[0.65rem] tracking-widest" style={{ fontFamily: "inherit" }}>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<DialogPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setQuery("");
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Backdrop
|
||||
className="fixed inset-0 z-50"
|
||||
style={{ background: "rgba(50,50,50,0.45)" }}
|
||||
/>
|
||||
<DialogPrimitive.Popup
|
||||
className="fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 outline-none"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
border: "2px solid var(--foreground)",
|
||||
boxShadow: "6px 6px 0 0 var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">Поиск по админке</DialogPrimitive.Title>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
label="Поиск по админке"
|
||||
loop
|
||||
// The input is the only tabbable element here, and Base UI's focus
|
||||
// guards let Tab leak out of a single-element trap (seen live:
|
||||
// focus walked to the sidebar while the menu stayed open). In a
|
||||
// command menu arrows move the selection, so Tab simply stays put.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab") e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3"
|
||||
style={{ borderBottom: "2px solid var(--border)" }}
|
||||
>
|
||||
<Search size={16} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||
<Command.Input
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Студент, курс, урок или раздел…"
|
||||
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]"
|
||||
/>
|
||||
{showLoading && (
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
ищем…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Command.List className="max-h-[60vh] overflow-y-auto p-2">
|
||||
<Command.Empty className="px-2 py-6 text-center text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
{!active ? "Введите хотя бы два символа" : showLoading ? "Ищем…" : "Ничего не нашлось"}
|
||||
</Command.Empty>
|
||||
|
||||
{shown.users.length > 0 && (
|
||||
<Command.Group heading="Студенты и сотрудники" className={groupClass}>
|
||||
{shown.users.map((u) => (
|
||||
<Command.Item
|
||||
key={u.id}
|
||||
value={`user:${u.id}`}
|
||||
onSelect={() => go(`/admin/users/${u.id}`)}
|
||||
className={itemClass}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{u.name}</span>
|
||||
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{u.email}
|
||||
</span>
|
||||
</span>
|
||||
{u.role !== "student" && (
|
||||
<span className="tag-aubade">{ROLE_LABEL[u.role] ?? u.role}</span>
|
||||
)}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{shown.courses.length > 0 && (
|
||||
<Command.Group heading="Курсы" className={groupClass}>
|
||||
{shown.courses.map((c) => (
|
||||
<Command.Item
|
||||
key={c.id}
|
||||
value={`course:${c.id}`}
|
||||
onSelect={() => go(`/admin/courses/${c.id}`)}
|
||||
className={itemClass}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{c.title}</span>
|
||||
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{c.slug}
|
||||
</span>
|
||||
</span>
|
||||
{!c.published && <span className="tag-aubade">черновик</span>}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{shown.lessons.length > 0 && (
|
||||
<Command.Group heading="Уроки" className={groupClass}>
|
||||
{shown.lessons.map((l) => (
|
||||
<Command.Item
|
||||
key={l.id}
|
||||
value={`lesson:${l.id}`}
|
||||
onSelect={() =>
|
||||
go(`/admin/courses/${l.courseId}/modules/${l.moduleId}/lessons/${l.id}`)
|
||||
}
|
||||
className={itemClass}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{l.title}</span>
|
||||
<span className="block truncate text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{l.courseTitle}
|
||||
</span>
|
||||
</span>
|
||||
{!l.published && <span className="tag-aubade">черновик</span>}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{sections.length > 0 && (
|
||||
<Command.Group heading="Разделы" className={groupClass}>
|
||||
{sections.map((l) => (
|
||||
<Command.Item
|
||||
key={l.href}
|
||||
value={`section:${l.href}`}
|
||||
onSelect={() => go(l.href)}
|
||||
className={itemClass}
|
||||
>
|
||||
{l.label}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const links = [
|
||||
export const adminLinks = [
|
||||
{ href: "/admin/dashboard", label: "Обзор" },
|
||||
{ href: "/admin/courses", label: "Курсы" },
|
||||
{ href: "/admin/categories", label: "Категории" },
|
||||
@@ -21,7 +21,7 @@ export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{links.map(({ href, label }) => {
|
||||
{adminLinks.map(({ href, label }) => {
|
||||
const active =
|
||||
pathname === href ||
|
||||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { LogoutButton } from "@/components/layout/logout-button";
|
||||
import { AdminCommandMenu } from "@/components/admin/admin-command-menu";
|
||||
|
||||
export function AdminShell({
|
||||
children,
|
||||
@@ -56,6 +57,9 @@ export function AdminShell({
|
||||
Администратор
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-2 pt-4">
|
||||
<AdminCommandMenu onOpen={() => setOpen(false)} />
|
||||
</div>
|
||||
{/* Закрываем меню при переходе по ссылке на мобильном */}
|
||||
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto" onClick={() => setOpen(false)}>
|
||||
<AdminNav questionsBadge={questionsBadge} />
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
import { Drawer as Vaul } from "vaul";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// DS-2 Aubade overlay: graphite at 45%, no blur.
|
||||
const BACKDROP = "rgba(50,50,50,0.45)";
|
||||
|
||||
const MobileContext = createContext(false);
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Fires once the close animation has finished (safe moment to unmount). */
|
||||
onClosed?: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet for phones (Vaul), dressed in DS-2: 2px graphite top border,
|
||||
* 2px corners, a flat rectangular grab handle (no pill shapes in the system).
|
||||
*/
|
||||
export function AubadeDrawer({ open, onOpenChange, onClosed, children, className }: DrawerProps) {
|
||||
return (
|
||||
<MobileContext.Provider value={true}>
|
||||
<Vaul.Root
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onAnimationEnd={(isOpen) => {
|
||||
if (!isOpen) onClosed?.();
|
||||
}}
|
||||
>
|
||||
<Vaul.Portal>
|
||||
<Vaul.Overlay className="fixed inset-0 z-50" style={{ background: BACKDROP }} />
|
||||
<Vaul.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed inset-x-0 bottom-0 z-50 flex max-h-[90dvh] flex-col rounded-t-[2px] outline-none"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
borderTop: "2px solid var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-auto mt-3 mb-1 h-1.5 w-12 shrink-0"
|
||||
style={{ background: "var(--border)" }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto px-5 pt-2 pb-[max(1.25rem,env(safe-area-inset-bottom))]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Vaul.Content>
|
||||
</Vaul.Portal>
|
||||
</Vaul.Root>
|
||||
</MobileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered modal on desktop (Base UI Dialog), bottom sheet on phones (<768px).
|
||||
* Put a <ResponsiveDialogTitle> inside — it renders the right accessible title
|
||||
* for whichever primitive is active.
|
||||
*/
|
||||
export function ResponsiveDialog({ open, onOpenChange, onClosed, children, className }: DrawerProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AubadeDrawer open={open} onOpenChange={onOpenChange} onClosed={onClosed} className={className}>
|
||||
{children}
|
||||
</AubadeDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
onOpenChange(next);
|
||||
// The desktop modal has no exit animation, so it is safe to unmount now.
|
||||
if (!next) onClosed?.();
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Backdrop className="fixed inset-0 z-50" style={{ background: BACKDROP }} />
|
||||
<DialogPrimitive.Popup
|
||||
ref={popupRef}
|
||||
initialFocus={popupRef}
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-md max-h-[90dvh] -translate-x-1/2 -translate-y-1/2 overflow-y-auto p-6 outline-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
border: "2px solid var(--foreground)",
|
||||
boxShadow: "6px 6px 0 0 var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResponsiveDialogTitle({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const isMobile = useContext(MobileContext);
|
||||
return isMobile ? (
|
||||
<Vaul.Title className={className}>{children}</Vaul.Title>
|
||||
) : (
|
||||
<DialogPrimitive.Title className={className}>{children}</DialogPrimitive.Title>
|
||||
);
|
||||
}
|
||||
|
||||
/** Close control that works inside both variants. */
|
||||
export function ResponsiveDialogClose({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const isMobile = useContext(MobileContext);
|
||||
return isMobile ? (
|
||||
<Vaul.Close type="button" className={className} style={style} aria-label={ariaLabel}>
|
||||
{children}
|
||||
</Vaul.Close>
|
||||
) : (
|
||||
<DialogPrimitive.Close type="button" className={className} style={style} aria-label={ariaLabel}>
|
||||
{children}
|
||||
</DialogPrimitive.Close>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import { AubadeDrawer, ResponsiveDialogTitle } from "@/components/shared/responsive-dialog";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
@@ -22,16 +25,20 @@ interface Course {
|
||||
modules: Module[];
|
||||
}
|
||||
|
||||
export function CourseSidebar({
|
||||
function CourseNavBody({
|
||||
course,
|
||||
completedLessonIds = new Set(),
|
||||
completedLessonIds,
|
||||
pathname,
|
||||
onNavigate,
|
||||
animateProgress,
|
||||
}: {
|
||||
course: Course;
|
||||
completedLessonIds?: Set<string>;
|
||||
completedLessonIds: Set<string>;
|
||||
pathname: string;
|
||||
onNavigate: () => void;
|
||||
/** DS-2: one animated moment per page — only the visible copy animates. */
|
||||
animateProgress: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
const completedCount = course.modules
|
||||
.flatMap((m) => m.lessons)
|
||||
@@ -40,46 +47,11 @@ export function CourseSidebar({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
|
||||
style={{
|
||||
backgroundColor: "var(--background)",
|
||||
border: "1.5px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Навигация по курсу"
|
||||
>
|
||||
{open ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-30"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={[
|
||||
"w-64 flex flex-col overflow-y-auto",
|
||||
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
|
||||
"lg:sticky lg:shrink-0 lg:translate-x-0",
|
||||
open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
borderRight: "2px solid var(--border)",
|
||||
backgroundColor: "var(--background)",
|
||||
maxHeight: "calc(100vh - 53px)",
|
||||
top: "53px",
|
||||
}}
|
||||
>
|
||||
{/* Course title + progress */}
|
||||
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
|
||||
<Link
|
||||
href={`/courses/${course.slug}`}
|
||||
onClick={onNavigate}
|
||||
className="font-bold text-sm leading-snug block mb-1"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
@@ -87,6 +59,7 @@ export function CourseSidebar({
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={onNavigate}
|
||||
className="text-xs block underline mb-3"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
@@ -100,7 +73,13 @@ export function CourseSidebar({
|
||||
{completedCount} из {totalLessons} уроков
|
||||
</span>
|
||||
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
|
||||
{progressPct}%
|
||||
{/* The page's single animated moment (DS-2 §8); static under reduced motion. */}
|
||||
<NumberFlow
|
||||
value={progressPct}
|
||||
suffix="%"
|
||||
animated={animateProgress}
|
||||
respectMotionPreference
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -108,7 +87,7 @@ export function CourseSidebar({
|
||||
style={{ background: "var(--border)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all duration-300"
|
||||
className="h-full transition-all duration-300 motion-reduce:transition-none"
|
||||
style={{
|
||||
width: `${progressPct}%`,
|
||||
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
|
||||
@@ -146,7 +125,7 @@ export function CourseSidebar({
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => onNavigate()}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
|
||||
style={{
|
||||
borderLeftColor: active ? "var(--foreground)" : "transparent",
|
||||
@@ -173,7 +152,84 @@ export function CourseSidebar({
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CourseSidebar({
|
||||
course,
|
||||
completedLessonIds = new Set(),
|
||||
}: {
|
||||
course: Course;
|
||||
completedLessonIds?: Set<string>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
// Tablet (768–1023px): the old off-canvas panel. Phone (<768px): bottom sheet.
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const menuOpen = isMobile ? sheetOpen : open;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
|
||||
style={{
|
||||
backgroundColor: "var(--background)",
|
||||
border: "1.5px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
onClick={() => (isMobile ? setSheetOpen((v) => !v) : setOpen((v) => !v))}
|
||||
aria-label="Навигация по курсу"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{menuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Overlay */}
|
||||
{open && !isMobile && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-30"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={[
|
||||
"w-64 flex flex-col overflow-y-auto",
|
||||
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
|
||||
"lg:sticky lg:shrink-0 lg:translate-x-0",
|
||||
open && !isMobile ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
borderRight: "2px solid var(--border)",
|
||||
backgroundColor: "var(--background)",
|
||||
maxHeight: "calc(100vh - 53px)",
|
||||
top: "53px",
|
||||
}}
|
||||
>
|
||||
<CourseNavBody
|
||||
course={course}
|
||||
completedLessonIds={completedLessonIds}
|
||||
pathname={pathname}
|
||||
onNavigate={() => setOpen(false)}
|
||||
animateProgress={!isMobile}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{isMobile && (
|
||||
<AubadeDrawer open={sheetOpen} onOpenChange={setSheetOpen} className="px-0 pt-0">
|
||||
<ResponsiveDialogTitle className="sr-only">Навигация по курсу</ResponsiveDialogTitle>
|
||||
<CourseNavBody
|
||||
course={course}
|
||||
completedLessonIds={completedLessonIds}
|
||||
pathname={pathname}
|
||||
onNavigate={() => setSheetOpen(false)}
|
||||
animateProgress
|
||||
/>
|
||||
</AubadeDrawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
|
||||
import { useEffect, useId, useState, type CSSProperties } from "react";
|
||||
import {
|
||||
PAY_METHODS_URL,
|
||||
PAY_ORDER_URL,
|
||||
type StoreCatalogEntry,
|
||||
} from "@/lib/store-catalog";
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogClose,
|
||||
ResponsiveDialogTitle,
|
||||
} from "@/components/shared/responsive-dialog";
|
||||
|
||||
export interface LockedCourse {
|
||||
id: string;
|
||||
@@ -25,7 +30,7 @@ interface PayMethod {
|
||||
interface Props {
|
||||
course: LockedCourse;
|
||||
entry: StoreCatalogEntry;
|
||||
buyer: { name: string; email: string };
|
||||
buyer: { name: string; email: string; phone?: string | null };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -50,7 +55,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
const [method, setMethod] = useState("robokassa");
|
||||
const [otherOpen, setOtherOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
// Kept open locally so the phone sheet can finish its close animation
|
||||
// before the parent unmounts us via onClose.
|
||||
const [open, setOpen] = useState(true);
|
||||
const titleId = useId();
|
||||
|
||||
const multiTariff = entry.tariffs.length > 1;
|
||||
@@ -71,31 +78,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// Lock body scroll while the modal is open
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Focus the dialog on open, restore focus on close
|
||||
useEffect(() => {
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.focus();
|
||||
return () => prev?.focus();
|
||||
}, []);
|
||||
|
||||
// Coming back from the payment page via bfcache must not leave the
|
||||
// submit button stuck in the "submitting" state.
|
||||
useEffect(() => {
|
||||
@@ -106,50 +88,12 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, []);
|
||||
|
||||
// Minimal focus trap: keep Tab cycling inside the dialog
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
|
||||
const primary = methods?.[0];
|
||||
const rest = methods?.slice(1) ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center px-4"
|
||||
style={{ background: "rgba(50,50,50,0.45)" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full max-w-md max-h-[90dvh] overflow-y-auto p-6 space-y-4"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "2px solid var(--foreground)",
|
||||
boxShadow: "6px 6px 0 0 var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<ResponsiveDialog open={open} onOpenChange={setOpen} onClosed={onClose} className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
@@ -158,15 +102,13 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
>
|
||||
Покупка курса
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
<ResponsiveDialogClose
|
||||
aria-label="Закрыть"
|
||||
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
|
||||
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</ResponsiveDialogClose>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -186,9 +128,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
📚
|
||||
</div>
|
||||
)}
|
||||
<h2 id={titleId} className="font-bold text-lg leading-tight">
|
||||
<ResponsiveDialogTitle className="font-bold text-lg leading-tight">
|
||||
{course.title}
|
||||
</h2>
|
||||
</ResponsiveDialogTitle>
|
||||
</div>
|
||||
|
||||
{course.description && (
|
||||
@@ -315,6 +257,34 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone — required by payment-router since 20261111 (owner messages buyers in Telegram) */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className={labelClass}
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
htmlFor={`${titleId}-phone`}
|
||||
>
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
id={`${titleId}-phone`}
|
||||
type="tel"
|
||||
name="phone"
|
||||
required
|
||||
maxLength={30}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
defaultValue={buyer.phone ?? ""}
|
||||
placeholder="+7 900 000-00-00"
|
||||
style={inputStyle}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
Напишу в Telegram, если с оплатой что-то пойдёт не так.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Promo code */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
@@ -366,7 +336,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||
после оплаты.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { StoreCheckoutModal, type LockedCourse } from "./store-checkout-modal";
|
||||
|
||||
interface Props {
|
||||
courses: LockedCourse[];
|
||||
buyer: { name: string; email: string };
|
||||
buyer: { name: string; email: string; phone?: string | null };
|
||||
}
|
||||
|
||||
// Blurred card body shared by both the modal-trigger button and the
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
/** Design-system breakpoint (DS-2 Aubade): below 768px is the mobile layout. */
|
||||
export const MOBILE_QUERY = "(max-width: 767px)";
|
||||
|
||||
/**
|
||||
* Subscribes to a CSS media query. On the server and during hydration it
|
||||
* returns `false`, so markup rendered on the server is the desktop variant and
|
||||
* the mobile one takes over right after hydration.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
const mql = window.matchMedia(query);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(query).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery(MOBILE_QUERY);
|
||||
}
|
||||
@@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => {
|
||||
expect(html).not.toContain("Отвечает <strong>");
|
||||
expect(html).toContain("Привет!");
|
||||
});
|
||||
|
||||
it("делает ссылки в тексте ответа кликабельными", async () => {
|
||||
await sendCommentReplyEmail(
|
||||
"student@example.com",
|
||||
"Екатерина",
|
||||
"6.3. Холст",
|
||||
"Подарок тут: https://obsidian.second-brain.ru/secret-bonus?a=1&b=2. Пишите!",
|
||||
LESSON_URL
|
||||
);
|
||||
|
||||
const html = sent[0].html;
|
||||
expect(html).toContain(
|
||||
'<a href="https://obsidian.second-brain.ru/secret-bonus?a=1&b=2" target="_blank"'
|
||||
);
|
||||
expect(html).toContain("secret-bonus?a=1&b=2</a>. Пишите!");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DEV_PAY_STUB_BASE, LIVE_PAY_BASE, resolvePayBase } from "../pay-base";
|
||||
|
||||
describe("resolvePayBase", () => {
|
||||
it("dev без переменной — локальная заглушка", () => {
|
||||
expect(resolvePayBase({ nodeEnv: "development" })).toBe(DEV_PAY_STUB_BASE);
|
||||
expect(resolvePayBase({ nodeEnv: "test" })).toBe(DEV_PAY_STUB_BASE);
|
||||
expect(resolvePayBase({})).toBe(DEV_PAY_STUB_BASE);
|
||||
});
|
||||
|
||||
it("production без переменной — боевой роутер", () => {
|
||||
expect(resolvePayBase({ nodeEnv: "production" })).toBe(LIVE_PAY_BASE);
|
||||
expect(resolvePayBase({ nodeEnv: "production", payBaseUrl: " " })).toBe(LIVE_PAY_BASE);
|
||||
});
|
||||
|
||||
it("явная переменная побеждает в любом режиме", () => {
|
||||
expect(resolvePayBase({ nodeEnv: "production", payBaseUrl: "http://localhost:9" })).toBe("http://localhost:9");
|
||||
expect(resolvePayBase({ nodeEnv: "development", payBaseUrl: "https://pay.example/" })).toBe("https://pay.example");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SlidingWindowLimiter,
|
||||
almatyDate,
|
||||
botReason,
|
||||
emailQuery,
|
||||
isPlausibleEmail,
|
||||
mergeAttribs,
|
||||
parseQuizLead,
|
||||
planExisting,
|
||||
shouldSendResult,
|
||||
type ListmonkSubscriber,
|
||||
} from "../quiz-lead";
|
||||
|
||||
const NOW = new Date("2026-11-20T10:00:00Z");
|
||||
const QUIZ_LIST = 48;
|
||||
|
||||
const v2 = (extra: Record<string, unknown> = {}) =>
|
||||
parseQuizLead({ email: " Reader@Example.com ", quiz_type: "t3-ready", utm_source: "youtube", ...extra })!;
|
||||
|
||||
const v1 = () =>
|
||||
parseQuizLead({ email: "a@example.com", archetype: "gardener", score: 70, utm_source: "telegram" })!;
|
||||
|
||||
/** Подписчик Press и покупатель — тот, кого прежний код и выбрасывал из Press. */
|
||||
const pressBuyer = (extra: Partial<ListmonkSubscriber> = {}): ListmonkSubscriber => ({
|
||||
id: 7,
|
||||
email: "reader@example.com",
|
||||
name: "Читатель",
|
||||
status: "enabled",
|
||||
lists: [
|
||||
{ id: 3, subscription_status: "confirmed" },
|
||||
{ id: 58, subscription_status: "unconfirmed" },
|
||||
{ id: 13, subscription_status: "unsubscribed" },
|
||||
],
|
||||
attribs: { course: "obsidian", updates_until: "20270101", utm_source: "first-touch" },
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("quiz-lead: разбор входа", () => {
|
||||
it("v2: тип по проблеме, почта нормализуется", () => {
|
||||
const l = v2({ quiz_answers: "1a 2b", utm_content: "{{utm_content}}", started_at: 1000, send_result: true });
|
||||
expect(l.email).toBe("reader@example.com");
|
||||
expect(l.quizType).toBe("t3-ready");
|
||||
expect(l.archetype).toBeNull();
|
||||
expect(l.answers).toBe("1a 2b");
|
||||
expect(l.utm).toEqual({ source: "youtube", medium: null, campaign: null, content: null });
|
||||
expect(l.startedAt).toBe(1000);
|
||||
expect(l.sendResult).toBe(true);
|
||||
});
|
||||
|
||||
it("v1 (опубликованный бот) проходит как раньше", () => {
|
||||
const l = v1();
|
||||
expect(l.archetype).toBe("gardener");
|
||||
expect(l.score).toBe(70);
|
||||
expect(l.quizType).toBeNull();
|
||||
expect(l.sendResult).toBe(false);
|
||||
});
|
||||
|
||||
it("отказ: чужой тип, неполный v1, мусор", () => {
|
||||
expect(parseQuizLead({ email: "a@example.com", quiz_type: "t9" })).toBeNull();
|
||||
expect(parseQuizLead({ email: "a@example.com", archetype: "gardener" })).toBeNull();
|
||||
expect(parseQuizLead({ email: "a@example.com" })).toBeNull();
|
||||
expect(parseQuizLead(null)).toBeNull();
|
||||
expect(parseQuizLead("x")).toBeNull();
|
||||
});
|
||||
|
||||
it("почта с кавычкой или пробелом не принимается — она уходит в SQL-фильтр Listmonk", () => {
|
||||
expect(isPlausibleEmail("a@example.com")).toBe(true);
|
||||
expect(isPlausibleEmail("laukhin+test@dementiy.com")).toBe(true);
|
||||
expect(isPlausibleEmail("x'or'1'='1@example.com")).toBe(false);
|
||||
expect(isPlausibleEmail("a b@example.com")).toBe(false);
|
||||
expect(isPlausibleEmail("a@localhost")).toBe(false);
|
||||
expect(isPlausibleEmail("a".repeat(65) + "@example.com")).toBe(false);
|
||||
expect(parseQuizLead({ email: "x'@example.com", quiz_type: "t1-start" })).toBeNull();
|
||||
});
|
||||
|
||||
it("фильтр экранирует кавычку на всякий случай", () => {
|
||||
expect(emailQuery("o'neil@example.com")).toBe("subscribers.email='o''neil@example.com'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quiz-lead: отсев ботов", () => {
|
||||
it("быстрее 20 секунд — бот, медленнее — человек, без времени (v1) — не судим", () => {
|
||||
const t = NOW.getTime();
|
||||
expect(botReason(v2({ started_at: t - 5_000 }), t)).toMatch(/^too-fast/);
|
||||
expect(botReason(v2({ started_at: t - 90_000 }), t)).toBeNull();
|
||||
expect(botReason(v1(), t)).toBeNull();
|
||||
});
|
||||
|
||||
it("лимит в окне: сверх лимита — отказ, после окна — снова можно", () => {
|
||||
const lim = new SlidingWindowLimiter(2, 1000);
|
||||
expect(lim.allow("a", 0)).toBe(true);
|
||||
expect(lim.allow("a", 10)).toBe(true);
|
||||
expect(lim.allow("a", 20)).toBe(false);
|
||||
expect(lim.allow("b", 20)).toBe(true);
|
||||
expect(lim.allow("a", 1011)).toBe(true);
|
||||
});
|
||||
|
||||
it("письмо-результат — не чаще раза в сутки и только по просьбе бота", () => {
|
||||
const l = v2({ send_result: true });
|
||||
expect(shouldSendResult(l, {}, NOW)).toBe(true);
|
||||
expect(shouldSendResult(l, { quiz_result_sent_at: "2026-11-20T01:00:00Z" }, NOW)).toBe(false);
|
||||
expect(shouldSendResult(l, { quiz_result_sent_at: "2026-11-19T09:00:00Z" }, NOW)).toBe(true);
|
||||
expect(shouldSendResult(v2(), {}, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quiz-lead: существующий подписчик — ничего чужого не трогаем", () => {
|
||||
it("списки и их статусы остаются, квиз добавляется отдельным вызовом, preconfirm выключен", () => {
|
||||
const plan = planExisting(pressBuyer(), v2(), QUIZ_LIST, NOW);
|
||||
expect(plan.kind).toBe("update");
|
||||
if (plan.kind !== "update") return;
|
||||
expect(plan.addToList).toBe(true);
|
||||
expect(plan.put.lists).toEqual([3, 58, 13, QUIZ_LIST]);
|
||||
expect(plan.put.preconfirm_subscriptions).toBe(false);
|
||||
expect(plan.put.status).toBe("enabled");
|
||||
expect(plan.put.name).toBe("Читатель");
|
||||
});
|
||||
|
||||
it("атрибуты дописываются, первая метка прихода не перетирается", () => {
|
||||
const plan = planExisting(pressBuyer(), v2({ quiz_answers: "ответы" }), QUIZ_LIST, NOW);
|
||||
if (plan.kind !== "update") throw new Error("ожидали update");
|
||||
expect(plan.put.attribs).toMatchObject({
|
||||
course: "obsidian",
|
||||
updates_until: "20270101",
|
||||
utm_source: "first-touch",
|
||||
quiz_type: "t3-ready",
|
||||
quiz_date: "20261120",
|
||||
quiz_answers: "ответы",
|
||||
quiz_utm: { source: "youtube", medium: null, campaign: null, content: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("уже в списке квиза (даже отписан) — заново не добавляем", () => {
|
||||
const sub = pressBuyer({ lists: [{ id: 3 }, { id: QUIZ_LIST, subscription_status: "unsubscribed" }] });
|
||||
const plan = planExisting(sub, v2(), QUIZ_LIST, NOW);
|
||||
if (plan.kind !== "update") throw new Error("ожидали update");
|
||||
expect(plan.addToList).toBe(false);
|
||||
expect(plan.put.lists).toEqual([3, QUIZ_LIST]);
|
||||
});
|
||||
|
||||
it("заблокированного не трогаем и не разблокируем", () => {
|
||||
expect(planExisting(pressBuyer({ status: "blocklisted" }), v2(), QUIZ_LIST, NOW)).toEqual({
|
||||
kind: "skip",
|
||||
reason: "blocklisted",
|
||||
});
|
||||
});
|
||||
|
||||
it("v1 пишет archetype и score, общий utm — только если его не было", () => {
|
||||
const fresh = mergeAttribs({}, v1(), NOW, false);
|
||||
expect(fresh).toEqual({ archetype: "gardener", score: 70, utm_source: "telegram" });
|
||||
const kept = mergeAttribs({ utm_source: "press" }, v1(), NOW, false);
|
||||
expect(kept.utm_source).toBe("press");
|
||||
});
|
||||
|
||||
it("отметка об отправке письма ставится только когда письмо уходит", () => {
|
||||
const plan = planExisting(pressBuyer(), v2({ send_result: true }), QUIZ_LIST, NOW);
|
||||
if (plan.kind !== "update") throw new Error("ожидали update");
|
||||
expect(plan.sendResult).toBe(true);
|
||||
expect(plan.put.attribs.quiz_result_sent_at).toBe(NOW.toISOString());
|
||||
const quiet = planExisting(pressBuyer(), v2(), QUIZ_LIST, NOW);
|
||||
if (quiet.kind !== "update") throw new Error("ожидали update");
|
||||
expect(quiet.put.attribs.quiz_result_sent_at).toBeUndefined();
|
||||
});
|
||||
|
||||
it("дата квиза — по Алматы", () => {
|
||||
expect(almatyDate(new Date("2026-11-19T20:30:00Z"))).toBe("20261120");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cleanUtm, parseUtmCookie, pickUtm } from "../utm-cookie";
|
||||
|
||||
describe("utm-cookie", () => {
|
||||
it("разбирает сырую строку запроса из cookie", () => {
|
||||
expect(parseUtmCookie("utm_source=youtube&utm_medium=video&utm_campaign=test-20261103&fbclid=x")).toEqual({
|
||||
utmSource: "youtube",
|
||||
utmMedium: "video",
|
||||
utmCampaign: "test-20261103",
|
||||
});
|
||||
});
|
||||
|
||||
it("понимает percent-кодирование", () => {
|
||||
expect(parseUtmCookie("utm_campaign=a%2Db_c.d").utmCampaign).toBe("a-b_c.d");
|
||||
});
|
||||
|
||||
it("пустая и битая cookie — пустые метки", () => {
|
||||
expect(parseUtmCookie(undefined)).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
|
||||
expect(parseUtmCookie("")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
|
||||
expect(parseUtmCookie("%%%")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
|
||||
});
|
||||
|
||||
it("отбрасывает значения не из белого списка символов и слишком длинные", () => {
|
||||
expect(cleanUtm("ok-1")).toBe("ok-1");
|
||||
expect(cleanUtm("<script>")).toBe("");
|
||||
expect(cleanUtm("с пробелом")).toBe("");
|
||||
expect(cleanUtm("a".repeat(150))).toBe("a".repeat(100));
|
||||
expect(parseUtmCookie("utm_source=x;Domain=evil").utmSource).toBe("");
|
||||
});
|
||||
|
||||
it("метки из адреса важнее cookie, поля не смешиваются", () => {
|
||||
const cookie = { utmSource: "vk", utmMedium: "video", utmCampaign: "old" };
|
||||
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "new" }, cookie)).toEqual({
|
||||
utmSource: "",
|
||||
utmMedium: "",
|
||||
utmCampaign: "new",
|
||||
});
|
||||
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "" }, cookie)).toEqual(cookie);
|
||||
});
|
||||
});
|
||||
+15
-1
@@ -1,5 +1,6 @@
|
||||
import { Resend } from "resend";
|
||||
import { getSetting } from "./settings";
|
||||
import { URL_RE } from "./linkify";
|
||||
|
||||
function getResend() {
|
||||
return new Resend(process.env.RESEND_API_KEY);
|
||||
@@ -97,11 +98,24 @@ function escapeHtml(text: string) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку.
|
||||
// Экранируем по частям, чтобы `&` в адресе не превратился в `&` внутри href дважды.
|
||||
function linkifyHtml(text: string) {
|
||||
return text
|
||||
.split(URL_RE)
|
||||
.map((part, i) =>
|
||||
i % 2 === 1
|
||||
? `<a href="${escapeHtml(part)}" target="_blank" style="color:#323232;text-decoration:underline;word-break:break-all;">${escapeHtml(part)}</a>`
|
||||
: escapeHtml(part)
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function quote(text: string) {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
|
||||
<tr>
|
||||
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
|
||||
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p>
|
||||
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${linkifyHtml(text).replace(/\n/g, "<br/>")}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
|
||||
const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
|
||||
export const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
|
||||
|
||||
export function linkify(text: string): ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Where the storefront checkout talks to: the payment router's /pay/order and
|
||||
// /pay/methods. Production builds default to the live router; everything else
|
||||
// (next dev, tests) defaults to the local stub so a stray click can't create a
|
||||
// real order. An explicit NEXT_PUBLIC_PAY_BASE_URL always wins.
|
||||
//
|
||||
// ⚠️ NODE_ENV is "production" for a local `next build && next start` too —
|
||||
// such a build hits the live router unless NEXT_PUBLIC_PAY_BASE_URL is set.
|
||||
|
||||
export const LIVE_PAY_BASE = "https://obsidian.second-brain.ru";
|
||||
export const DEV_PAY_STUB_BASE = "/dev/pay-stub";
|
||||
|
||||
export function resolvePayBase(env: { payBaseUrl?: string; nodeEnv?: string }): string {
|
||||
const explicit = env.payBaseUrl?.trim();
|
||||
if (explicit) return explicit.replace(/\/+$/, "");
|
||||
return env.nodeEnv === "production" ? LIVE_PAY_BASE : DEV_PAY_STUB_BASE;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Приём лида из квиза Typebot (`/api/internal/quiz-lead`) — чистая логика.
|
||||
*
|
||||
* Почему отдельный модуль (20261107, квиз v2): прежний обработчик для
|
||||
* существующего подписчика делал `PUT /api/subscribers/{id}` с `lists: [48]`
|
||||
* и новыми `attribs`. Listmonk заменяет этим вызовом списки и атрибуты
|
||||
* ЦЕЛИКОМ — подписчик Press, прошедший квиз, выпадал из Press, у покупателя
|
||||
* пропадали `course` и `updates_until`, а `status: "enabled"` снимал
|
||||
* блокировку с бот-адресов. Проверено опытом на Listmonk 6.2 20261107.
|
||||
*
|
||||
* Правильный путь (тот же, что `payment-router/app/lite_subscribe.py`):
|
||||
* 1. в список квиза — только `PUT /api/subscribers/lists` (action add),
|
||||
* другие подписки не трогаются;
|
||||
* 2. атрибуты — `PUT /api/subscribers/{id}` с ТЕКУЩИМИ списками, текущим
|
||||
* статусом и `preconfirm_subscriptions: false` (с `true` Listmonk
|
||||
* подтверждает все неподтверждённые подписки человека — карточка SBT
|
||||
* `listmonk.md`, кейс 20260927);
|
||||
* 3. заблокированного не трогаем вовсе.
|
||||
*/
|
||||
|
||||
export const QUIZ_TYPES = ["t1-start", "t2-archive", "t3-ready", "t4-ai", "t5-team"] as const;
|
||||
export type QuizType = (typeof QUIZ_TYPES)[number];
|
||||
|
||||
/** Архетипы квиза v1 — бот v1 ещё опубликован, его вызовы должны проходить. */
|
||||
export const LEGACY_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
|
||||
export type LegacyArchetype = (typeof LEGACY_ARCHETYPES)[number];
|
||||
|
||||
/** Человек не проходит 10 вопросов быстрее — быстрее отвечает только скрипт. */
|
||||
export const MIN_QUIZ_SECONDS = 20;
|
||||
/** Письмо-результат — не чаще раза в сутки на адрес. */
|
||||
export const RESULT_RESEND_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const MAX_ANSWERS_LEN = 2000;
|
||||
const MAX_UTM_LEN = 200;
|
||||
|
||||
export type QuizUtm = { source: string | null; medium: string | null; campaign: string | null; content: string | null };
|
||||
|
||||
export type QuizLead = {
|
||||
email: string;
|
||||
/** v2: тип по проблеме; v1: архетип + «зрелость». Ровно одно из двух. */
|
||||
quizType: QuizType | null;
|
||||
archetype: LegacyArchetype | null;
|
||||
score: number | null;
|
||||
answers: string | null;
|
||||
utm: QuizUtm;
|
||||
/** Время старта квиза, мс с эпохи — для отсева скриптов. v1 его не шлёт. */
|
||||
startedAt: number | null;
|
||||
/** Просит ли бот отправить письмо-результат через LMS (v2). */
|
||||
sendResult: boolean;
|
||||
};
|
||||
|
||||
// Без кавычек, пробелов и угловых скобок: адрес уходит в SQL-фильтр Listmonk.
|
||||
const EMAIL_RE = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,24}$/;
|
||||
|
||||
export function isPlausibleEmail(email: string): boolean {
|
||||
return email.length <= 254 && EMAIL_RE.test(email);
|
||||
}
|
||||
|
||||
function str(v: unknown, max: number): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
if (!s || s === "null" || s === "undefined") return null;
|
||||
// Неподставленная переменная Typebot приходит как «{{utm_source}}».
|
||||
if (s.startsWith("{{") && s.endsWith("}}")) return null;
|
||||
return s.slice(0, max);
|
||||
}
|
||||
|
||||
export function parseQuizLead(body: unknown): QuizLead | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
const email = String(b.email ?? "").trim().toLowerCase();
|
||||
if (!isPlausibleEmail(email)) return null;
|
||||
|
||||
const rawType = str(b.quiz_type, 32);
|
||||
const quizType = rawType && (QUIZ_TYPES as readonly string[]).includes(rawType) ? (rawType as QuizType) : null;
|
||||
const rawArch = str(b.archetype, 32);
|
||||
const archetype =
|
||||
rawArch && (LEGACY_ARCHETYPES as readonly string[]).includes(rawArch) ? (rawArch as LegacyArchetype) : null;
|
||||
const scoreNum = b.score == null || b.score === "" ? NaN : Number(b.score);
|
||||
const score = Number.isFinite(scoreNum) ? scoreNum : null;
|
||||
|
||||
if (rawType != null && !quizType) return null; // прислали тип, но не наш
|
||||
if (!quizType && !(archetype && score != null)) return null; // ни v2, ни полного v1
|
||||
|
||||
const startedNum = Number(b.started_at);
|
||||
return {
|
||||
email,
|
||||
quizType,
|
||||
archetype: quizType ? null : archetype,
|
||||
score: quizType ? null : score,
|
||||
answers: str(b.quiz_answers, MAX_ANSWERS_LEN),
|
||||
utm: {
|
||||
source: str(b.utm_source, MAX_UTM_LEN),
|
||||
medium: str(b.utm_medium, MAX_UTM_LEN),
|
||||
campaign: str(b.utm_campaign, MAX_UTM_LEN),
|
||||
content: str(b.utm_content, MAX_UTM_LEN),
|
||||
},
|
||||
startedAt: Number.isFinite(startedNum) && startedNum > 0 ? startedNum : null,
|
||||
sendResult: b.send_result === true || b.send_result === "true",
|
||||
};
|
||||
}
|
||||
|
||||
/** Причина считать вызов ботом или null. Бота не выдаём ответом — молча пропускаем. */
|
||||
export function botReason(lead: QuizLead, nowMs: number): string | null {
|
||||
if (lead.startedAt != null) {
|
||||
const sec = (nowMs - lead.startedAt) / 1000;
|
||||
if (sec >= 0 && sec < MIN_QUIZ_SECONDS) return `too-fast:${sec.toFixed(1)}s`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** YYYYMMDD по Алматы — `quiz_date` для писем дня 2/5/9. */
|
||||
export function almatyDate(now: Date): string {
|
||||
const d = new Date(now.getTime() + 5 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты после квиза: существующие сохраняются, квизовые перезаписываются,
|
||||
* общие `utm_*` — только если их ещё нет (это метка ПЕРВОГО прихода, её
|
||||
* нельзя перетирать повторным квизом).
|
||||
*/
|
||||
export function mergeAttribs(
|
||||
existing: Record<string, unknown> | null | undefined,
|
||||
lead: QuizLead,
|
||||
now: Date,
|
||||
resultSent: boolean,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...(existing ?? {}) };
|
||||
if (lead.quizType) {
|
||||
out.quiz_type = lead.quizType;
|
||||
out.quiz_date = almatyDate(now);
|
||||
if (lead.answers) out.quiz_answers = lead.answers;
|
||||
out.quiz_utm = { ...lead.utm };
|
||||
} else {
|
||||
out.archetype = lead.archetype;
|
||||
out.score = lead.score;
|
||||
}
|
||||
const firstTouch: Array<[string, string | null]> = [
|
||||
["utm_source", lead.utm.source],
|
||||
["utm_medium", lead.utm.medium],
|
||||
["utm_campaign", lead.utm.campaign],
|
||||
];
|
||||
for (const [k, v] of firstTouch) {
|
||||
if (v != null && (out[k] == null || out[k] === "")) out[k] = v;
|
||||
}
|
||||
if (resultSent) out.quiz_result_sent_at = now.toISOString();
|
||||
return out;
|
||||
}
|
||||
|
||||
export type ListmonkSubscriber = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
status: string; // enabled | disabled | blocklisted
|
||||
lists: Array<{ id: number; subscription_status?: string }>;
|
||||
attribs: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function shouldSendResult(lead: QuizLead, attribs: Record<string, unknown> | null | undefined, now: Date): boolean {
|
||||
if (!lead.sendResult) return false;
|
||||
const last = attribs?.quiz_result_sent_at;
|
||||
if (typeof last === "string") {
|
||||
const t = Date.parse(last);
|
||||
if (Number.isFinite(t) && now.getTime() - t < RESULT_RESEND_MS) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ExistingPlan =
|
||||
| { kind: "skip"; reason: "blocklisted" }
|
||||
| {
|
||||
kind: "update";
|
||||
/** Нужно ли добавить в список квиза отдельным `PUT /api/subscribers/lists`. */
|
||||
addToList: boolean;
|
||||
sendResult: boolean;
|
||||
/** Тело `PUT /api/subscribers/{id}` — только атрибуты, списки и статус как есть. */
|
||||
put: {
|
||||
email: string;
|
||||
name: string;
|
||||
status: string;
|
||||
lists: number[];
|
||||
attribs: Record<string, unknown>;
|
||||
preconfirm_subscriptions: false;
|
||||
};
|
||||
};
|
||||
|
||||
// РЕШЕНИЕ D-BIZ-017: список — через subscribers/lists add, атрибуты — PUT с текущими списками без preconfirm
|
||||
export function planExisting(sub: ListmonkSubscriber, lead: QuizLead, listId: number, now: Date): ExistingPlan {
|
||||
if (sub.status === "blocklisted") return { kind: "skip", reason: "blocklisted" };
|
||||
const current = sub.lists.map((l) => l.id);
|
||||
// Любая запись о списке квиза — даже «отписан» — значит, добавлять не нужно:
|
||||
// `action: add` поверх отписки вернул бы человека в список против его воли.
|
||||
const addToList = !current.includes(listId);
|
||||
const sendResult = shouldSendResult(lead, sub.attribs, now);
|
||||
return {
|
||||
kind: "update",
|
||||
addToList,
|
||||
sendResult,
|
||||
put: {
|
||||
email: sub.email,
|
||||
name: sub.name || lead.email.split("@")[0],
|
||||
status: sub.status,
|
||||
lists: addToList ? [...current, listId] : current,
|
||||
attribs: mergeAttribs(sub.attribs, lead, now, sendResult),
|
||||
preconfirm_subscriptions: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Значение для SQL-фильтра Listmonk `subscribers.email='…'`. */
|
||||
export function emailQuery(email: string): string {
|
||||
return `subscribers.email='${email.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Скользящее окно в памяти процесса: контейнер приложения один, рестарт окно сбрасывает — это допустимо. */
|
||||
export class SlidingWindowLimiter {
|
||||
private hits = new Map<string, number[]>();
|
||||
constructor(
|
||||
private readonly limit: number,
|
||||
private readonly windowMs: number,
|
||||
) {}
|
||||
|
||||
allow(key: string, nowMs: number): boolean {
|
||||
const from = nowMs - this.windowMs;
|
||||
const list = (this.hits.get(key) ?? []).filter((t) => t > from);
|
||||
if (list.length >= this.limit) {
|
||||
this.hits.set(key, list);
|
||||
return false;
|
||||
}
|
||||
list.push(nowMs);
|
||||
this.hits.set(key, list);
|
||||
if (this.hits.size > 10_000) this.prune(from);
|
||||
return true;
|
||||
}
|
||||
|
||||
private prune(from: number) {
|
||||
for (const [k, v] of this.hits) {
|
||||
const kept = v.filter((t) => t > from);
|
||||
if (kept.length) this.hits.set(k, kept);
|
||||
else this.hits.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
// price here is a display bug, not a money bug. When a price changes:
|
||||
// products.yaml → landing → this file.
|
||||
|
||||
import { resolvePayBase } from "@/lib/pay-base";
|
||||
|
||||
export interface StoreTariff {
|
||||
sku: string; // must match payment-router/products.yaml
|
||||
label: string;
|
||||
@@ -17,8 +19,13 @@ export interface StoreCatalogEntry {
|
||||
tariffs: StoreTariff[];
|
||||
}
|
||||
|
||||
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order";
|
||||
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
|
||||
// Literal process.env.* reads so Next inlines them into the client bundle.
|
||||
const PAY_BASE = resolvePayBase({
|
||||
payBaseUrl: process.env.NEXT_PUBLIC_PAY_BASE_URL,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
});
|
||||
export const PAY_ORDER_URL = `${PAY_BASE}/pay/order`;
|
||||
export const PAY_METHODS_URL = `${PAY_BASE}/pay/methods`;
|
||||
|
||||
// Keys = course slugs shown as locked cards on the student dashboard.
|
||||
// Only tariffs of the course's "native" landing are listed — cross-bundles
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Метки utm с лендингов (20261103, задание CMO «utm_campaign в LMS»).
|
||||
//
|
||||
// Лендинги второго мозга — поддомены second-brain.ru за общим Caddy на проде.
|
||||
// Caddy при заходе с utm_* в адресе кладёт сырую строку запроса в cookie
|
||||
// `sb_utm` на домен .second-brain.ru (см. фрагмент `(sb_utm)` в prod.caddy).
|
||||
// Кнопка лендинга ведёт на /register без меток, поэтому страница регистрации
|
||||
// берёт их из адреса, а если там пусто — из этой cookie.
|
||||
|
||||
export const UTM_COOKIE = "sb_utm";
|
||||
|
||||
export type Utm = { utmSource: string; utmMedium: string; utmCampaign: string };
|
||||
|
||||
const EMPTY: Utm = { utmSource: "", utmMedium: "", utmCampaign: "" };
|
||||
|
||||
// Значение метки: только латиница, цифры, точка, дефис, подчёркивание; до 100 символов.
|
||||
// Остальное (кириллица, пробелы, кавычки) отбрасываем целиком — в отчёт идут слаги.
|
||||
export function cleanUtm(v: string | null | undefined): string {
|
||||
if (!v) return "";
|
||||
const s = v.trim().slice(0, 100);
|
||||
return /^[A-Za-z0-9._-]+$/.test(s) ? s : "";
|
||||
}
|
||||
|
||||
export function parseUtmCookie(raw: string | null | undefined): Utm {
|
||||
if (!raw) return EMPTY;
|
||||
let q: URLSearchParams;
|
||||
try {
|
||||
q = new URLSearchParams(raw.length > 2000 ? raw.slice(0, 2000) : raw);
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
return {
|
||||
utmSource: cleanUtm(q.get("utm_source")),
|
||||
utmMedium: cleanUtm(q.get("utm_medium")),
|
||||
utmCampaign: cleanUtm(q.get("utm_campaign")),
|
||||
};
|
||||
}
|
||||
|
||||
// Метки из адреса важнее cookie: прямой переход на /register?utm_… — самый свежий.
|
||||
// Берём набор целиком из одного источника, не смешиваем поля.
|
||||
export function pickUtm(fromQuery: Utm, fromCookie: Utm): Utm {
|
||||
const q = {
|
||||
utmSource: cleanUtm(fromQuery.utmSource),
|
||||
utmMedium: cleanUtm(fromQuery.utmMedium),
|
||||
utmCampaign: cleanUtm(fromQuery.utmCampaign),
|
||||
};
|
||||
if (q.utmSource || q.utmMedium || q.utmCampaign) return q;
|
||||
return fromCookie;
|
||||
}
|
||||
Reference in New Issue
Block a user