9 Commits
Author SHA1 Message Date
adminsandClaude Opus 5.5 981ea3a893 Витрина: телефон в заказе обязателен, предзаполнен из профиля
payment-router с 20261111 требует телефон (слово владельца: писать
покупателю в Telegram, если оплата не прошла). Поле видимое, обязательное,
подставляется User.phone, если есть.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:22:10 +05:00
adminsandClaude Opus 5.5 62d76916ca Route storefront checkout to a local stub outside production
The checkout form and payment-method lookup were hardcoded to the live
payment router, so any local LMS (next dev included) created real
orders: a stray test click on 20261111 produced order 1794380127.

- resolvePayBase(): NEXT_PUBLIC_PAY_BASE_URL if set, otherwise the live
  router for production builds and /dev/pay-stub for everything else.
- /dev/pay-stub/pay/{methods,order}: dev-only stand-ins (404 in
  production) that make no outbound calls and store nothing.
- Document in CLAUDE.md that a local `next build && next start` still
  hits the live router unless NEXT_PUBLIC_PAY_BASE_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:12 +05:00
adminsandClaude Opus 5.5 ba6da38b9c Keep Tab inside the admin command menu
The search input is the only tabbable element in the menu, and Base UI's
focus guards let Tab leak out of a single-element trap: focus walked to
the sidebar while the menu stayed open. Arrow keys drive the selection
in a command menu, so Tab now stays on the input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:11:19 +05:00
adminsandClaude Opus 5.5 7a231cf92f Add pick-ui-library skill with project constraints
Copied from emilkowalski/skills (MIT, commit 715c180), invoked manually
only. Appended a project section: shadcn base-nova on Base UI, DS-2
rules, one animated element per page, no new deps without approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:40:30 +05:00
adminsandClaude Opus 5.5 197bbb80ec Limit motion to one animated element per page (DS-2)
- Course navigation: only the completion percent animates; the
  "N of M lessons" counter is static. NumberFlow is enabled only in the
  visible copy of the navigation (drawer on phones, sidebar otherwise).
- globals.css: disable Vaul drawer and overlay animation under
  prefers-reduced-motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:26:44 +05:00
adminsandClaude Opus 5.5 09b069cbb5 Add admin command menu, mobile drawer and animated course progress
- cmdk 1.1.1: Cmd/Ctrl+K command menu in the admin panel (students,
  courses, lessons, admin sections) plus a "Search" button in the sidebar.
  New read-only endpoint GET /api/admin/search with the same admin role
  check as /api/admin/export-users; query length 2-100 chars.
- vaul 1.1.2: ResponsiveDialog wrapper (Base UI Dialog on desktop, bottom
  drawer below 768px) styled per DS-2. Used for the storefront checkout on
  the dashboard and for course navigation on phones (tablet and desktop
  unchanged).
- @number-flow/react 0.6.2: animated "N of M lessons" and completion
  percent in course navigation; respects prefers-reduced-motion.

No migrations. Not deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:25:52 +05:00
adminsandClaude Opus 5.5 74d51f72bf Remove Resend API key from TECHNICAL.md
The key was masked on the build server on 20261105 but never committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:39:42 +05:00
adminsandClaude Opus 5.5 42518b2137 Mark quiz-lead merge logic with decision D-BIZ-017
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:38:12 +05:00
adminsandClaude Opus 5.5 767a0d5a6e Fix quiz-lead wiping lists and attributes of existing subscribers
PUT /api/subscribers/{id} with lists=[quiz] replaced all lists and attribs:
a Press reader taking the quiz dropped out of Press, buyers lost course and
updates_until, status "enabled" lifted blocklisting. Verified on Listmonk 6.2.

Now: add to the quiz list via PUT /api/subscribers/lists (action add), write
attribs with current lists and status and preconfirm_subscriptions=false,
leave blocklisted subscribers untouched, keep first-touch utm_*.

Bot protection: constant-time secret check, quizzes faster than 20 s are
dropped silently, global hourly cap and per-email cap, strict email check
(the address goes into a Listmonk SQL filter), result email at most once a
day per address. Quiz v2 fields: quiz_type, quiz_date, quiz_answers,
quiz_utm, optional result email through LMS (send_result). v1 bot payload
keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:17:54 +05:00
25 changed files with 2260 additions and 303 deletions
+95
View File
@@ -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.
+11
View File
@@ -21,6 +21,12 @@ TURNSTILE_SECRET_KEY=""
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID. # (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
NEXT_PUBLIC_METRIKA_COUNTER_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
KINESCOPE_API_KEY="" KINESCOPE_API_KEY=""
@@ -37,6 +43,11 @@ LISTMONK_URL="https://newsletter.second-brain.ru"
LISTMONK_USER="apiuser" LISTMONK_USER="apiuser"
LISTMONK_TOKEN="" LISTMONK_TOKEN=""
LISTMONK_QUIZ_LIST_ID="" 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 «ежедневные заметки») — после его завершения # Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
# показываем лестницу-оффер (трипвайр + полный курс) # показываем лестницу-оффер (трипвайр + полный курс)
+1
View File
@@ -220,6 +220,7 @@ S3_REGION="eu-central"
### Env ### 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`. - `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
### Тариф — это отдельный курс со своей копией уроков ### Тариф — это отдельный курс со своей копией уроков
+1 -1
View File
@@ -39,7 +39,7 @@ docker compose -f docker-compose.prod.yml up -d --build
``` ```
DB_PASSWORD=lms_cd5041e961a3050db359aa15 DB_PASSWORD=lms_cd5041e961a3050db359aa15
BETTER_AUTH_SECRET=<secret> BETTER_AUTH_SECRET=<secret>
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL RESEND_API_KEY=re_…(ключ в .env, не в документации)
EMAIL_FROM=noreply@mailsend.second-brain.ru EMAIL_FROM=noreply@mailsend.second-brain.ru
S3_ENDPOINT=https://nbg1.your-objectstorage.com S3_ENDPOINT=https://nbg1.your-objectstorage.com
S3_BUCKET=second-brain-lms S3_BUCKET=second-brain-lms
+784 -3
View File
@@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4", "@kinescope/react-kinescope-player": "^0.5.4",
"@number-flow/react": "^0.6.2",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
@@ -28,6 +29,7 @@
"better-auth": "^1.6.0", "better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"defuddle": "^0.19.1", "defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62", "disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4", "force-graph": "^1.51.4",
@@ -49,6 +51,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -2822,6 +2825,20 @@
"node": ">=12.4.0" "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": { "node_modules/@opentelemetry/api": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -3096,7 +3113,557 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "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", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -3112,7 +3679,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "1.2.3" "@radix-ui/react-slot": "1.2.3"
@@ -3136,7 +3702,6 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2" "@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": { "node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "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==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0" "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": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -6831,6 +7441,22 @@
"node": ">=6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7424,6 +8050,12 @@
"node": ">=8" "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": { "node_modules/devlop": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8176,6 +8808,12 @@
"url": "https://opencollective.com/eslint" "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": { "node_modules/espree": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -8706,6 +9344,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-port-please": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "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" "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": { "node_modules/nypm": {
"version": "0.6.5", "version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -12586,6 +13242,75 @@
"react": ">=18" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -14243,6 +14968,49 @@
"punycode": "^2.1.0" "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": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "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": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+3
View File
@@ -21,6 +21,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4", "@kinescope/react-kinescope-player": "^0.5.4",
"@number-flow/react": "^0.6.2",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
@@ -34,6 +35,7 @@
"better-auth": "^1.6.0", "better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"defuddle": "^0.19.1", "defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62", "disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4", "force-graph": "^1.51.4",
@@ -55,6 +57,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -14,7 +14,7 @@ export default async function StudentDashboard() {
const dbUser = await prisma.user.findUnique({ const dbUser = await prisma.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { archetype: true }, select: { archetype: true, phone: true },
}); });
const enrollments = await prisma.courseEnrollment.findMany({ const enrollments = await prisma.courseEnrollment.findMany({
@@ -168,7 +168,7 @@ export default async function StudentDashboard() {
{locked.length > 0 && ( {locked.length > 0 && (
<StoreShowcase <StoreShowcase
courses={locked} courses={locked}
buyer={{ name: session.user.name, email: session.user.email }} buyer={{ name: session.user.name, email: session.user.email, phone: dbUser?.phone }}
/> />
)} )}
+61
View File
@@ -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,
})),
});
}
+110 -101
View File
@@ -1,59 +1,79 @@
import { NextResponse } from "next/server"; 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 { const MAX_PER_HOUR = Number(process.env.QUIZ_LEAD_MAX_PER_HOUR) || 60;
id: number; 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 { function lm(path: string): string {
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 {
return `${process.env.LISTMONK_URL}${path}`; return `${process.env.LISTMONK_URL}${path}`;
} }
function getListId(): number { async function lmJson(path: string, method: string, body?: unknown): Promise<Response> {
return Number(process.env.LISTMONK_QUIZ_LIST_ID); return fetch(lm(path), {
method,
headers: { "Content-Type": "application/json", Authorization: auth() },
body: body === undefined ? undefined : JSON.stringify(body),
});
} }
function isValidEmail(email: string): boolean { async function findSubscriber(email: string): Promise<ListmonkSubscriber | null> {
return /^[^@]+@[^@]+\.[^@]+$/.test(email); 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 { async function sendResult(lead: QuizLead): Promise<void> {
return (ARCHETYPES as readonly string[]).includes(value); 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> { export async function POST(req: Request): Promise<NextResponse> {
// --- Auth --- if (!isAuthorized(req.headers.get("x-quiz-secret"), process.env.QUIZ_LEAD_SECRET)) {
const secret = req.headers.get("x-quiz-secret");
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 }); return NextResponse.json({ error: "unauthorized" }, { status: 401 });
} }
// --- Parse body ---
let body: unknown; let body: unknown;
try { try {
body = await req.json(); body = await req.json();
@@ -61,87 +81,76 @@ export async function POST(req: Request): Promise<NextResponse> {
return NextResponse.json({ error: "bad json" }, { status: 400 }); return NextResponse.json({ error: "bad json" }, { status: 400 });
} }
// --- Validate fields --- const lead = parseQuizLead(body);
const raw = body as Record<string, unknown>; if (!lead) return NextResponse.json({ error: "invalid fields" }, { status: 400 });
const email = String(raw.email ?? "")
.trim()
.toLowerCase();
const archetype = String(raw.archetype ?? "");
const score = Number(raw.score);
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) { const now = new Date();
return NextResponse.json({ error: "invalid fields" }, { status: 400 }); 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 = { const listId = Number(process.env.LISTMONK_QUIZ_LIST_ID);
archetype, if (!listId) {
score, console.error("quiz-lead: LISTMONK_QUIZ_LIST_ID is not set");
utm_source: raw.utm_source != null ? String(raw.utm_source) : null, return NextResponse.json({ error: "not configured" }, { status: 503 });
utm_medium: raw.utm_medium != null ? String(raw.utm_medium) : null, }
utm_campaign: raw.utm_campaign != null ? String(raw.utm_campaign) : null,
};
const auth = buildAuth();
const listId = getListId();
// --- Upsert to Listmonk ---
try { try {
const createRes = await fetch(listmonkUrl("/api/subscribers"), { const sendNow = shouldSendResult(lead, null, now);
method: "POST", const createRes = await lmJson("/api/subscribers", "POST", {
headers: { "Content-Type": "application/json", Authorization: auth }, email: lead.email,
body: JSON.stringify({ name: lead.email.split("@")[0],
email,
name: email.split("@")[0],
lists: [listId],
attribs,
status: "enabled", status: "enabled",
lists: [listId],
// Новый подписчик — только в одном списке квиза, подтверждать больше нечего.
preconfirm_subscriptions: true, preconfirm_subscriptions: true,
}), attribs: mergeAttribs({}, lead, now, sendNow),
}); });
if (createRes.status === 409) { if (createRes.ok) {
// Subscriber exists — look up by email and update if (sendNow) await sendResult(lead);
const searchRes = await fetch( return NextResponse.json({ ok: true });
listmonkUrl("/api/subscribers?query=") + }
encodeURIComponent(`subscribers.email='${email}'`), if (createRes.status !== 409) {
{ headers: { Authorization: auth } }, console.error("quiz-lead: listmonk create failed", createRes.status, (await createRes.text()).slice(0, 300));
); return NextResponse.json({ error: "listmonk" }, { status: 502 });
const searchJson = (await searchRes.json()) as ListmonkSearchResult; }
const existingId = searchJson?.data?.results?.[0]?.id;
if (!existingId) { const existing = await findSubscriber(lead.email);
console.error("quiz-lead: subscriber lookup failed for", email); if (!existing) {
console.error("quiz-lead: 409, but subscriber not found");
return NextResponse.json({ error: "lookup failed" }, { status: 502 }); return NextResponse.json({ error: "lookup failed" }, { status: 502 });
} }
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), { const plan = planExisting(existing, lead, listId, now);
method: "PUT", if (plan.kind === "skip") return NextResponse.json({ ok: true });
headers: { "Content-Type": "application/json", Authorization: auth },
body: JSON.stringify({
email,
name: email.split("@")[0],
lists: [listId],
attribs,
status: "enabled",
}),
});
if (!updateRes.ok) { if (plan.addToList) {
console.error( const addRes = await lmJson("/api/subscribers/lists", "PUT", {
"quiz-lead: subscriber update failed", ids: [existing.id],
updateRes.status, action: "add",
await updateRes.text(), 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 }); return NextResponse.json({ error: "listmonk" }, { status: 502 });
} }
} else if (!createRes.ok) { }
console.error(
"quiz-lead: listmonk create failed", const putRes = await lmJson(`/api/subscribers/${existing.id}`, "PUT", plan.put);
createRes.status, if (!putRes.ok) {
await createRes.text(), console.error("quiz-lead: attribs update failed", putRes.status, (await putRes.text()).slice(0, 300));
);
return NextResponse.json({ error: "listmonk" }, { status: 502 }); return NextResponse.json({ error: "listmonk" }, { status: 502 });
} }
if (plan.sendResult) await sendResult(lead);
} catch (e) { } catch (e) {
console.error("quiz-lead: upstream error", e); console.error("quiz-lead: upstream error", e);
return NextResponse.json({ error: "upstream" }, { status: 502 }); return NextResponse.json({ error: "upstream" }, { status: 502 });
+15
View File
@@ -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" },
],
});
}
+28
View File
@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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" } });
}
+11
View File
@@ -298,3 +298,14 @@
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
} }
.comment-md pre code { background: none; padding: 0; } .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;
}
}
+277
View File
@@ -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>
</>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
const links = [ export const adminLinks = [
{ href: "/admin/dashboard", label: "Обзор" }, { href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" }, { href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" }, { href: "/admin/categories", label: "Категории" },
@@ -21,7 +21,7 @@ export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
return ( return (
<> <>
{links.map(({ href, label }) => { {adminLinks.map(({ href, label }) => {
const active = const active =
pathname === href || pathname === href ||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) || (href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
+4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import { AdminNav } from "@/components/admin/admin-nav"; import { AdminNav } from "@/components/admin/admin-nav";
import { LogoutButton } from "@/components/layout/logout-button"; import { LogoutButton } from "@/components/layout/logout-button";
import { AdminCommandMenu } from "@/components/admin/admin-command-menu";
export function AdminShell({ export function AdminShell({
children, children,
@@ -56,6 +57,9 @@ export function AdminShell({
Администратор Администратор
</p> </p>
</div> </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)}> <nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto" onClick={() => setOpen(false)}>
<AdminNav questionsBadge={questionsBadge} /> <AdminNav questionsBadge={questionsBadge} />
+148
View File
@@ -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>
);
}
+101 -45
View File
@@ -4,6 +4,9 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Menu, X } from "lucide-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 { interface Lesson {
id: string; id: string;
@@ -22,16 +25,20 @@ interface Course {
modules: Module[]; modules: Module[];
} }
export function CourseSidebar({ function CourseNavBody({
course, course,
completedLessonIds = new Set(), completedLessonIds,
pathname,
onNavigate,
animateProgress,
}: { }: {
course: Course; 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 totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
const completedCount = course.modules const completedCount = course.modules
.flatMap((m) => m.lessons) .flatMap((m) => m.lessons)
@@ -40,46 +47,11 @@ export function CourseSidebar({
return ( 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 */} {/* Course title + progress */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}> <div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link <Link
href={`/courses/${course.slug}`} href={`/courses/${course.slug}`}
onClick={onNavigate}
className="font-bold text-sm leading-snug block mb-1" className="font-bold text-sm leading-snug block mb-1"
style={{ color: "var(--foreground)" }} style={{ color: "var(--foreground)" }}
> >
@@ -87,6 +59,7 @@ export function CourseSidebar({
</Link> </Link>
<Link <Link
href="/dashboard" href="/dashboard"
onClick={onNavigate}
className="text-xs block underline mb-3" className="text-xs block underline mb-3"
style={{ color: "var(--muted-foreground)" }} style={{ color: "var(--muted-foreground)" }}
> >
@@ -100,7 +73,13 @@ export function CourseSidebar({
{completedCount} из {totalLessons} уроков {completedCount} из {totalLessons} уроков
</span> </span>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}> <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> </span>
</div> </div>
<div <div
@@ -108,7 +87,7 @@ export function CourseSidebar({
style={{ background: "var(--border)" }} style={{ background: "var(--border)" }}
> >
<div <div
className="h-full transition-all duration-300" className="h-full transition-all duration-300 motion-reduce:transition-none"
style={{ style={{
width: `${progressPct}%`, width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)", background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
@@ -146,7 +125,7 @@ export function CourseSidebar({
<Link <Link
key={lesson.id} key={lesson.id}
href={`/courses/${course.slug}/lessons/${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" className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{ style={{
borderLeftColor: active ? "var(--foreground)" : "transparent", borderLeftColor: active ? "var(--foreground)" : "transparent",
@@ -173,7 +152,84 @@ export function CourseSidebar({
); );
})} })}
</nav> </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> </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>
)}
</> </>
); );
} }
+44 -75
View File
@@ -1,11 +1,16 @@
"use client"; "use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react"; import { useEffect, useId, useState, type CSSProperties } from "react";
import { import {
PAY_METHODS_URL, PAY_METHODS_URL,
PAY_ORDER_URL, PAY_ORDER_URL,
type StoreCatalogEntry, type StoreCatalogEntry,
} from "@/lib/store-catalog"; } from "@/lib/store-catalog";
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogTitle,
} from "@/components/shared/responsive-dialog";
export interface LockedCourse { export interface LockedCourse {
id: string; id: string;
@@ -25,7 +30,7 @@ interface PayMethod {
interface Props { interface Props {
course: LockedCourse; course: LockedCourse;
entry: StoreCatalogEntry; entry: StoreCatalogEntry;
buyer: { name: string; email: string }; buyer: { name: string; email: string; phone?: string | null };
onClose: () => void; onClose: () => void;
} }
@@ -50,7 +55,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
const [method, setMethod] = useState("robokassa"); const [method, setMethod] = useState("robokassa");
const [otherOpen, setOtherOpen] = useState(false); const [otherOpen, setOtherOpen] = useState(false);
const [submitting, setSubmitting] = 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 titleId = useId();
const multiTariff = entry.tariffs.length > 1; const multiTariff = entry.tariffs.length > 1;
@@ -71,31 +78,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
.catch(() => {}); .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 // Coming back from the payment page via bfcache must not leave the
// submit button stuck in the "submitting" state. // submit button stuck in the "submitting" state.
useEffect(() => { useEffect(() => {
@@ -106,50 +88,12 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
return () => window.removeEventListener("pageshow", onPageShow); 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 labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
const primary = methods?.[0]; const primary = methods?.[0];
const rest = methods?.slice(1) ?? []; const rest = methods?.slice(1) ?? [];
return ( return (
<div <ResponsiveDialog open={open} onOpenChange={setOpen} onClosed={onClose} className="space-y-4">
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)",
}}
>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p <p
@@ -158,15 +102,13 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
> >
Покупка курса Покупка курса
</p> </p>
<button <ResponsiveDialogClose
type="button"
onClick={onClose}
aria-label="Закрыть" aria-label="Закрыть"
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center" className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }} style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
> >
× ×
</button> </ResponsiveDialogClose>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -186,9 +128,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
📚 📚
</div> </div>
)} )}
<h2 id={titleId} className="font-bold text-lg leading-tight"> <ResponsiveDialogTitle className="font-bold text-lg leading-tight">
{course.title} {course.title}
</h2> </ResponsiveDialogTitle>
</div> </div>
{course.description && ( {course.description && (
@@ -315,6 +257,34 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
</div> </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 */} {/* Promo code */}
<div className="mb-4"> <div className="mb-4">
<label <label
@@ -366,7 +336,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
после оплаты. после оплаты.
</p> </p>
</div> </div>
</div> </ResponsiveDialog>
</div>
); );
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import { StoreCheckoutModal, type LockedCourse } from "./store-checkout-modal";
interface Props { interface Props {
courses: LockedCourse[]; 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 // Blurred card body shared by both the modal-trigger button and the
+32
View File
@@ -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);
}
+20
View File
@@ -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");
});
});
+169
View File
@@ -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");
});
});
+16
View File
@@ -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;
}
+244
View File
@@ -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);
}
}
}
+9 -2
View File
@@ -5,6 +5,8 @@
// price here is a display bug, not a money bug. When a price changes: // price here is a display bug, not a money bug. When a price changes:
// products.yaml → landing → this file. // products.yaml → landing → this file.
import { resolvePayBase } from "@/lib/pay-base";
export interface StoreTariff { export interface StoreTariff {
sku: string; // must match payment-router/products.yaml sku: string; // must match payment-router/products.yaml
label: string; label: string;
@@ -17,8 +19,13 @@ export interface StoreCatalogEntry {
tariffs: StoreTariff[]; tariffs: StoreTariff[];
} }
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order"; // Literal process.env.* reads so Next inlines them into the client bundle.
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods"; 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. // Keys = course slugs shown as locked cards on the student dashboard.
// Only tariffs of the course's "native" landing are listed — cross-bundles // Only tariffs of the course's "native" landing are listed — cross-bundles