Author SHA1 Message Date
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
adminsandClaude Opus 5.5 651a115ab7 Регистрация: utm с лендинга из cookie sb_utm, если в адресе меток нет
Кнопки лендингов ведут на /register без меток, поэтому utmCampaign не был
записан ни у одного пользователя. Caddy кладёт строку запроса с utm_* в cookie
sb_utm на .second-brain.ru; страница регистрации берёт метки из адреса, а если
их нет — из cookie. Значения: [A-Za-z0-9._-], до 100 символов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:16:09 +05:00
adminsandClaude Opus 5.5 d8777a7a23 Кликабельные ссылки: история фидбека у куратора и цитаты в письмах
Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было.
В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал:
кликабельность зависела от почтовика. Теперь ссылки оформлены <a>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 14:48:59 +05:00
adminsandClaude Opus 5.5 4792df274e access-check: точное сравнение почты (без ILIKE-шаблонов), заблокированным — нет, секрет за постоянное время
По фоновой проверке безопасности: mode insensitive превращался в ILIKE,
и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь
считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем
регистре (0 из 1426 с заглавными), вход нормализуется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:06:06 +05:00
adminsandClaude Opus 5.5 83f34ea679 Внутренний метод /api/internal/access-check: есть ли у почты доступ к курсу
Для бота выдачи SB Access (payment-router): старый покупатель Second Brain
Vault оставляет почту на /tg/link, ключ входа в бота выдаётся, только если
доступ к курсу действительно есть. Тот же общий секрет, что у grant;
ответ только да/нет, почта без учёта регистра, истёкший доступ не считается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:03:24 +05:00
adminsandClaude Opus 5.5 0a5368577c баннер архетипа: кнопка «Демо-хранилище» → «Second Brain Vault»
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:55:12 +05:00
adminsandClaude Opus 5.5 82737ba188 ROADMAP: служебные учётки на проде — к удалению по решению владельца
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:05:54 +05:00
adminsandClaude Opus 5.5 9553d1e9be ROADMAP: бэклог — дефекты сброса и смены пароля (флаг, /change-password, Math.random)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:05:06 +05:00
adminsandClaude Opus 5.5 e2311facf4 Vault: цена 2990 → 4990 ₽ с 20261001
На прод выкатывается таймером vault-price-4990 на Hoster.kz
(/root/price-switch-20261001/switch.py) в 00:00 МСК.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:10:06 +05:00
adminsandClaude Opus 5 30d3523aeb docs(CLAUDE): грабли Markdown при ответе студенту в комментариях
Комментарии к урокам рендерятся через react-markdown + remark-gfm, и
это меняет то, как надо писать сами ответы. Проверено на уроке
Zotero 2.3: четыре пункта строками через «—» в ответе от 21.09
склеились в один абзац (GFM не считает тире маркером списка), а
длинный адрес с параметрами запроса автолинк обрывает на «&».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:11:10 +05:00
adminsandClaude Opus 5 02443f0c5c docs(уклад): правила по уведомлениям и письмам
Выяснилось при выкате писем об ответах под уроками: отправка глотает ошибки,
уведомления висят на серверном действии (а не на таблице), в проекте были две
расходящиеся копии addComment, дефолты настроек живут в коде.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 12:57:53 +05:00
35 changed files with 2497 additions and 307 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.
NEXT_PUBLIC_METRIKA_COUNTER_ID=""
# Адрес payment-router для чекаута витрины (/pay/order, /pay/methods). Build-time.
# Пусто: dev → локальная заглушка /dev/pay-stub, production-сборка → боевой
# https://obsidian.second-brain.ru. Для локальной `next build && next start`
# задать явно (например http://localhost:9), иначе форма уйдёт на прод.
NEXT_PUBLIC_PAY_BASE_URL=""
# Kinescope API
KINESCOPE_API_KEY=""
@@ -37,6 +43,11 @@ LISTMONK_URL="https://newsletter.second-brain.ru"
LISTMONK_USER="apiuser"
LISTMONK_TOKEN=""
LISTMONK_QUIZ_LIST_ID=""
# Квиз v2: письмо-результат через LMS (tx-шаблон с ветками по quiz_type); пусто — LMS писем не шлёт
LISTMONK_QUIZ_TX_TEMPLATE_ID=""
QUIZ_RESULT_FROM="Second Brain <delivery@mailsend.second-brain.ru>"
# Защита от ботов: потолок лидов квиза в час (по умолчанию 60)
QUIZ_LEAD_MAX_PER_HOUR=""
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
# показываем лестницу-оффер (трипвайр + полный курс)
+14
View File
@@ -197,6 +197,14 @@ S3_REGION="eu-central"
- **`src/proxy.ts` не существует** — вся защита маршрутов в `src/middleware.ts`, экспорт `middleware`. Не заводить второй файл.
- Новый API-роут, который авторизуется сам (Bearer-ключ, внутренний секрет), обязан попасть в `PUBLIC_ROUTES` — иначе middleware отдаст редирект на `/login` раньше роута, и в браузере с кукой это не воспроизведётся. Обратная сторона: сверка идёт по `startsWith`, поэтому запись `/api/pdf` уже открывает любой будущий `/api/pdf-*` — такой роут обязан проверять доступ внутри себя.
### Уведомления и письма
- **Отправка глотает ошибки.** Каждый `send*Email` заканчивается `.catch((e) => console.error(...))`, поэтому «серверное действие выполнилось» ничего не говорит о письме. Проверять боем только по Resend: `GET https://api.resend.com/emails?limit=…` и поле `last_event` (`delivered` / `bounced`). Ключ — `~/.config/secrets/resend.env`.
- **Уведомления висят на серверном действии, а не на таблице.** Строка, вставленная в базу напрямую (`INSERT INTO "LessonComment" …`), писем не шлёт — это удобно, чтобы подготовить сцену, но проверять нужно через интерфейс. При автоматизации браузером легко промахнуться мимо формы ответа и попасть в корневое поле: тогда `parentId` пустой, код-путь не вызывается и письма правомерно нет. Помечать поля до клика «Ответить» и брать `textarea:not([data-pre])`.
- **Перед правкой серверного действия проверить, кто его импортирует.** В проекте жили две расходящиеся копии `addComment`/`deleteComment`/`editComment`: живая в `src/lib/actions/student-actions.ts` (лимит 10 000) и мёртвая в `src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts` (лимит 2000, никем не импортировалась, удалена 20260915). Правка в неподключённой копии выглядит применённой и не даёт эффекта.
- **Дефолты настроек живут в коде.** В прод-таблице `Settings` строк `notify*` нет вообще — значения берутся из `SETTINGS_DEFAULTS` (`src/lib/settings.ts`), строка появляется только при первом сохранении формы в админке. Новый ключ с дефолтом `"true"` включается на проде сам, без миграции и без действий в интерфейсе.
- Лимиты превью в письмах разные по смыслу: `REPLY_PREVIEW_LIMIT = 2000` у тредов вопросов, `COMMENT_REPLY_PREVIEW_LIMIT = 4000` у ответов под уроками (ввод разрешает 10 000, разборы под уроком длиннее реплик в треде). Не сводить в одну константу.
### Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
- **Два разных значения `ToolUsage.tool`**: `clean-pdf-generate` (`PDF_TOOL_ID`) — только успешные генерации, по ним считаются месячный лимит и burst; `clean-pdf` — аналитика копирований из `CopyButton`. Не объединять: иначе каждое нажатие «Скопировать» списывает генерацию. `clean-pdf-generate` намеренно отсутствует в `TOOL_IDS`, чтобы публичный Server Action не мог списать квоту.
@@ -212,6 +220,7 @@ S3_REGION="eu-central"
### Env
- **Чекаут витрины ходит в боевой payment-router из любой production-сборки.** Адрес `/pay/order` и `/pay/methods` выбирает `resolvePayBase` (`src/lib/pay-base.ts`): `NEXT_PUBLIC_PAY_BASE_URL`, иначе при `NODE_ENV=production` — `https://obsidian.second-brain.ru`, иначе — локальная заглушка `/dev/pay-stub` (в проде 404, ничего не сохраняет и не шлёт). `next dev` безопасен, а **локальная `next build && next start` без `NEXT_PUBLIC_PAY_BASE_URL` отправит форму на прод**: реальный заказ, pending в леджере, Telegram владельцу (случай 20261111, заказ 1794380127). Для такой сборки задавать переменную явно, например `http://localhost:9`. Браузерные проверки — только с сетевой блокировкой внешних доменов и без отправки форм.
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
### Тариф — это отдельный курс со своей копией уроков
@@ -232,6 +241,11 @@ S3_REGION="eu-central"
`**жирный**` в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — `linkify` её поднимет.
Когда сами пишем ответ студенту под уроком, две граблю рендера (проверено боем 20260922, урок Zotero 2.3):
- **Перечисление строками через «—» склеивается в один абзац.** GFM не считает тире маркером списка: четыре пункта подряд, каждый с новой строки, отрендерились одной сплошной строкой в ответе от 21.09. Список — только `- ` в начале строки.
- **Длинный адрес с `&` оборачивать в `<…>`.** Автолинк GFM на параметрах запроса обрывается; `<https://host/?a=1&b=2>` даёт кликабельную ссылку целиком. Подчёркивания внутри слова (`url_selector`) курсивом не становятся — их экранировать не нужно.
Безопасность рендера комментариев (не ослаблять «улучшением»): `rehype-sanitize` режет сырой HTML и `javascript:`; картинки с чужих доменов **не грузятся** — компонент `img` отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через `/api/student/comment-upload`.
### Файлы курсов живут на двух разных доменах
+8
View File
@@ -275,6 +275,14 @@
## Бэклог (после MVP)
- **⚠️ Смена и сброс пароля — три дефекта** (найдено 20261005, когда владелец менял пароль админа):
1. `resetUserPassword` (`src/app/admin/users/[userId]/actions.ts`, ~стр. 82–99) заменяет пароль временным, но **не ставит `mustChangePassword: true`** — пользователя с временным паролем никто не заставляет его сменить.
2. Ученическая зона при `mustChangePassword` делает `redirect("/change-password")` (`src/app/(student)/layout.tsx`, ~стр. 23), а **страницы `/change-password` в `src/app` нет** — ученик с флагом попадёт на 404. Нужна страница (форма уже есть: `src/app/(student)/profile/change-password-form.tsx` + `changePasswordAction`, она же снимает флаг).
3. Временный пароль генерируется через `Math.random()` — не криптостойко; заменить на `crypto.randomInt` (node:crypto).
- Заодно: у админа/куратора нет смены пароля в админке — сейчас только через ученический `/profile` (работает, проверено 20261005). Добавить ссылку или форму в админ-профиль; рассмотреть принудительную смену и для админки.
- Приёмка: сброс → вход временным паролем → принудительная форма смены (и у ученика, и у админа) → флаг снят; тест на генерацию пароля.
- Гигиена прода: остались служебные учётки `student@second-brain.ru` (`student-seed-001`, сид 20260407) и `loadtest@second-brain.ru` (`loadtest-user-001`, 20260506). Пароль из сида к ним не подходит (проверено bcrypt 20261005), но учётки не нужны — удалить или заблокировать по решению владельца.
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
+1 -1
View File
@@ -39,7 +39,7 @@ docker compose -f docker-compose.prod.yml up -d --build
```
DB_PASSWORD=lms_cd5041e961a3050db359aa15
BETTER_AUTH_SECRET=<secret>
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
RESEND_API_KEY=re_…(ключ в .env, не в документации)
EMAIL_FROM=noreply@mailsend.second-brain.ru
S3_ENDPOINT=https://nbg1.your-objectstorage.com
S3_BUCKET=second-brain-lms
+784 -3
View File
@@ -15,6 +15,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4",
"@number-flow/react": "^0.6.2",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2",
@@ -28,6 +29,7 @@
"better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4",
@@ -49,6 +51,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -2822,6 +2825,20 @@
"node": ">=12.4.0"
}
},
"node_modules/@number-flow/react": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.2.tgz",
"integrity": "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4",
"number-flow": "0.6.2"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
@@ -3096,7 +3113,557 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.3.0",
"@radix-ui/react-dismissable-layer": "1.2.1",
"@radix-ui/react-focus-guards": "1.2.0",
"@radix-ui/react-focus-scope": "1.3.1",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-portal": "1.2.0",
"@radix-ui/react-presence": "1.1.11",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-slot": "1.5.0",
"@radix-ui/react-use-controllable-state": "1.3.0",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-use-effect-event": "0.0.5",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-callback-ref": "1.1.5",
"@radix-ui/react-use-effect-event": "0.0.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-callback-ref": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
@@ -3112,7 +3679,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
@@ -3136,7 +3702,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
@@ -3177,6 +3742,39 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
@@ -6051,6 +6649,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -6831,6 +7441,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7424,6 +8050,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -8176,6 +8808,12 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -8706,6 +9344,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-port-please": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
@@ -11574,6 +12221,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/number-flow": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.2.tgz",
"integrity": "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4"
}
},
"node_modules/nypm": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
@@ -12586,6 +13242,75 @@
"react": ">=18"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -14243,6 +14968,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -14287,6 +15055,19 @@
}
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+3
View File
@@ -21,6 +21,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4",
"@number-flow/react": "^0.6.2",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tiptap/extension-image": "^3.22.2",
@@ -34,6 +35,7 @@
"better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4",
@@ -55,6 +57,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
+10 -3
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form";
import { UTM_COOKIE, parseUtmCookie, pickUtm } from "@/lib/utm-cookie";
export default async function RegisterPage({
searchParams,
@@ -15,6 +17,11 @@ export default async function RegisterPage({
const sp = await searchParams;
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
// Метки: из адреса, а если их нет — из cookie лендинга (src/lib/utm-cookie.ts).
const utm = pickUtm(
{ utmSource: str(sp.utm_source), utmMedium: str(sp.utm_medium), utmCampaign: str(sp.utm_campaign) },
parseUtmCookie((await cookies()).get(UTM_COOKIE)?.value),
);
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
@@ -34,9 +41,9 @@ export default async function RegisterPage({
termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl}
archetype={str(sp.archetype)}
utmSource={str(sp.utm_source)}
utmMedium={str(sp.utm_medium)}
utmCampaign={str(sp.utm_campaign)}
utmSource={utm.utmSource}
utmMedium={utm.utmMedium}
utmCampaign={utm.utmCampaign}
gclid={str(sp.gclid)}
/>
</div>
+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,
})),
});
}
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { accessWhere, isAuthorized, parseAccessCheck } from "@/lib/access-check";
/**
* Internal: есть ли у почты действующий доступ к курсу.
* Auth: тот же общий секрет, что у /api/internal/grant (`x-internal-secret`).
*
* Body: { email, course_slug } → { ok: true, has_access: boolean }
*/
export async function POST(request: NextRequest) {
if (!isAuthorized(request.headers.get("x-internal-secret"), process.env.INTERNAL_GRANT_SECRET)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
}
const input = parseAccessCheck(body);
if (!input) {
return NextResponse.json({ ok: false, error: "missing_fields", need: ["email", "course_slug"] }, { status: 400 });
}
const found = await prisma.courseEnrollment.findFirst({
where: accessWhere(input, new Date()),
select: { userId: true },
});
return NextResponse.json({ ok: true, has_access: Boolean(found) });
}
+110 -101
View File
@@ -1,59 +1,79 @@
import { NextResponse } from "next/server";
import { isAuthorized } from "@/lib/access-check";
import {
SlidingWindowLimiter,
botReason,
emailQuery,
mergeAttribs,
parseQuizLead,
planExisting,
shouldSendResult,
type ListmonkSubscriber,
type QuizLead,
} from "@/lib/quiz-lead";
const ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
type Archetype = (typeof ARCHETYPES)[number];
/**
* POST /api/internal/quiz-lead — лид из квиза Typebot (вызов сервер-сервер).
*
* Подписчик попадает в список квиза, квизовые атрибуты дописываются к
* имеющимся — чужие списки, статусы подписок и атрибуты не трогаются
* (логика и обоснование — `src/lib/quiz-lead.ts`).
*
* Защита от ботов: секрет `x-quiz-secret`; квиз быстрее 20 с — молча
* пропускаем; общий лимит лидов в час и лимит на адрес; заблокированный в
* Listmonk адрес не трогаем; письмо-результат — не чаще раза в сутки на адрес.
* IP человека сюда не доходит (вызывает сервер Typebot), поэтому лимиты — не по IP.
*/
interface ListmonkSubscriber {
id: number;
const MAX_PER_HOUR = Number(process.env.QUIZ_LEAD_MAX_PER_HOUR) || 60;
const globalLimiter = new SlidingWindowLimiter(MAX_PER_HOUR, 60 * 60 * 1000);
const emailLimiter = new SlidingWindowLimiter(3, 60 * 60 * 1000);
function auth(): string {
return "Basic " + Buffer.from(`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`).toString("base64");
}
interface ListmonkSearchResult {
data?: {
results?: ListmonkSubscriber[];
};
}
function buildAuth(): string {
return (
"Basic " +
Buffer.from(
`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`,
).toString("base64")
);
}
function listmonkUrl(path: string): string {
function lm(path: string): string {
return `${process.env.LISTMONK_URL}${path}`;
}
function getListId(): number {
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
async function lmJson(path: string, method: string, body?: unknown): Promise<Response> {
return fetch(lm(path), {
method,
headers: { "Content-Type": "application/json", Authorization: auth() },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
function isValidEmail(email: string): boolean {
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
async function findSubscriber(email: string): Promise<ListmonkSubscriber | null> {
const res = await fetch(lm("/api/subscribers?per_page=1&query=") + encodeURIComponent(emailQuery(email)), {
headers: { Authorization: auth() },
});
if (!res.ok) throw new Error(`lookup ${res.status}`);
const json = (await res.json()) as { data?: { results?: ListmonkSubscriber[] } };
return json?.data?.results?.[0] ?? null;
}
function isValidArchetype(value: string): value is Archetype {
return (ARCHETYPES as readonly string[]).includes(value);
async function sendResult(lead: QuizLead): Promise<void> {
const templateId = Number(process.env.LISTMONK_QUIZ_TX_TEMPLATE_ID);
if (!templateId) {
console.error("quiz-lead: send_result requested, but LISTMONK_QUIZ_TX_TEMPLATE_ID is not set");
return;
}
const res = await lmJson("/api/tx", "POST", {
subscriber_email: lead.email,
template_id: templateId,
from_email: process.env.QUIZ_RESULT_FROM || "Second Brain <delivery@mailsend.second-brain.ru>",
data: { quiz_type: lead.quizType, utm: lead.utm },
});
if (!res.ok) throw new Error(`tx ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
/**
* POST /api/internal/quiz-lead
*
* Receives a completed Typebot quiz lead and idempotently upserts
* the subscriber in Listmonk with archetype + score attributes.
*
* Auth: x-quiz-secret header must match QUIZ_LEAD_SECRET env var.
*/
export async function POST(req: Request): Promise<NextResponse> {
// --- Auth ---
const secret = req.headers.get("x-quiz-secret");
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
if (!isAuthorized(req.headers.get("x-quiz-secret"), process.env.QUIZ_LEAD_SECRET)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// --- Parse body ---
let body: unknown;
try {
body = await req.json();
@@ -61,87 +81,76 @@ export async function POST(req: Request): Promise<NextResponse> {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
// --- Validate fields ---
const raw = body as Record<string, unknown>;
const email = String(raw.email ?? "")
.trim()
.toLowerCase();
const archetype = String(raw.archetype ?? "");
const score = Number(raw.score);
const lead = parseQuizLead(body);
if (!lead) return NextResponse.json({ error: "invalid fields" }, { status: 400 });
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) {
return NextResponse.json({ error: "invalid fields" }, { status: 400 });
const now = new Date();
const bot = botReason(lead, now.getTime());
if (bot) {
console.warn("quiz-lead: skipped as bot", bot);
return NextResponse.json({ ok: true });
}
if (!emailLimiter.allow(lead.email, now.getTime()) || !globalLimiter.allow("all", now.getTime())) {
console.warn("quiz-lead: rate limited");
return NextResponse.json({ error: "too many requests" }, { status: 429 });
}
const attribs = {
archetype,
score,
utm_source: raw.utm_source != null ? String(raw.utm_source) : null,
utm_medium: raw.utm_medium != null ? String(raw.utm_medium) : null,
utm_campaign: raw.utm_campaign != null ? String(raw.utm_campaign) : null,
};
const listId = Number(process.env.LISTMONK_QUIZ_LIST_ID);
if (!listId) {
console.error("quiz-lead: LISTMONK_QUIZ_LIST_ID is not set");
return NextResponse.json({ error: "not configured" }, { status: 503 });
}
const auth = buildAuth();
const listId = getListId();
// --- Upsert to Listmonk ---
try {
const createRes = await fetch(listmonkUrl("/api/subscribers"), {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: auth },
body: JSON.stringify({
email,
name: email.split("@")[0],
lists: [listId],
attribs,
const sendNow = shouldSendResult(lead, null, now);
const createRes = await lmJson("/api/subscribers", "POST", {
email: lead.email,
name: lead.email.split("@")[0],
status: "enabled",
lists: [listId],
// Новый подписчик — только в одном списке квиза, подтверждать больше нечего.
preconfirm_subscriptions: true,
}),
attribs: mergeAttribs({}, lead, now, sendNow),
});
if (createRes.status === 409) {
// Subscriber exists — look up by email and update
const searchRes = await fetch(
listmonkUrl("/api/subscribers?query=") +
encodeURIComponent(`subscribers.email='${email}'`),
{ headers: { Authorization: auth } },
);
const searchJson = (await searchRes.json()) as ListmonkSearchResult;
const existingId = searchJson?.data?.results?.[0]?.id;
if (createRes.ok) {
if (sendNow) await sendResult(lead);
return NextResponse.json({ ok: true });
}
if (createRes.status !== 409) {
console.error("quiz-lead: listmonk create failed", createRes.status, (await createRes.text()).slice(0, 300));
return NextResponse.json({ error: "listmonk" }, { status: 502 });
}
if (!existingId) {
console.error("quiz-lead: subscriber lookup failed for", email);
const existing = await findSubscriber(lead.email);
if (!existing) {
console.error("quiz-lead: 409, but subscriber not found");
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
}
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: auth },
body: JSON.stringify({
email,
name: email.split("@")[0],
lists: [listId],
attribs,
status: "enabled",
}),
});
const plan = planExisting(existing, lead, listId, now);
if (plan.kind === "skip") return NextResponse.json({ ok: true });
if (!updateRes.ok) {
console.error(
"quiz-lead: subscriber update failed",
updateRes.status,
await updateRes.text(),
);
if (plan.addToList) {
const addRes = await lmJson("/api/subscribers/lists", "PUT", {
ids: [existing.id],
action: "add",
target_list_ids: [listId],
status: "confirmed",
});
if (!addRes.ok) {
console.error("quiz-lead: add to list failed", addRes.status, (await addRes.text()).slice(0, 300));
return NextResponse.json({ error: "listmonk" }, { status: 502 });
}
} else if (!createRes.ok) {
console.error(
"quiz-lead: listmonk create failed",
createRes.status,
await createRes.text(),
);
}
const putRes = await lmJson(`/api/subscribers/${existing.id}`, "PUT", plan.put);
if (!putRes.ok) {
console.error("quiz-lead: attribs update failed", putRes.status, (await putRes.text()).slice(0, 300));
return NextResponse.json({ error: "listmonk" }, { status: 502 });
}
if (plan.sendResult) await sendResult(lead);
} catch (e) {
console.error("quiz-lead: upstream error", e);
return NextResponse.json({ error: "upstream" }, { status: 502 });
@@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) {
{new Date(fb.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
<p className="text-sm whitespace-pre-wrap mb-2">{linkify(fb.text)}</p>
{fbFiles.length > 0 && (
<div className="space-y-1 mt-2">
{fbFiles.map((f) => (
+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;
}
.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 { usePathname } from "next/navigation";
const links = [
export const adminLinks = [
{ href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" },
@@ -21,7 +21,7 @@ export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
return (
<>
{links.map(({ href, label }) => {
{adminLinks.map(({ href, label }) => {
const active =
pathname === href ||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
+4
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Menu, X } from "lucide-react";
import { AdminNav } from "@/components/admin/admin-nav";
import { LogoutButton } from "@/components/layout/logout-button";
import { AdminCommandMenu } from "@/components/admin/admin-command-menu";
export function AdminShell({
children,
@@ -56,6 +57,9 @@ export function AdminShell({
Администратор
</p>
</div>
<div className="px-2 pt-4">
<AdminCommandMenu onOpen={() => setOpen(false)} />
</div>
{/* Закрываем меню при переходе по ссылке на мобильном */}
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto" onClick={() => setOpen(false)}>
<AdminNav questionsBadge={questionsBadge} />
+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>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ const ARCHETYPE_INFO: Record<
emoji: "📚",
name: "Библиотекарь",
desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.",
cta: "Демо-хранилище",
cta: "Second Brain Vault",
href: "https://second-brain.ru/sb-vault",
},
pragmatist: {
+101 -45
View File
@@ -4,6 +4,9 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import NumberFlow from "@number-flow/react";
import { AubadeDrawer, ResponsiveDialogTitle } from "@/components/shared/responsive-dialog";
import { useIsMobile } from "@/hooks/use-media-query";
interface Lesson {
id: string;
@@ -22,16 +25,20 @@ interface Course {
modules: Module[];
}
export function CourseSidebar({
function CourseNavBody({
course,
completedLessonIds = new Set(),
completedLessonIds,
pathname,
onNavigate,
animateProgress,
}: {
course: Course;
completedLessonIds?: Set<string>;
completedLessonIds: Set<string>;
pathname: string;
onNavigate: () => void;
/** DS-2: one animated moment per page — only the visible copy animates. */
animateProgress: boolean;
}) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
const completedCount = course.modules
.flatMap((m) => m.lessons)
@@ -40,46 +47,11 @@ export function CourseSidebar({
return (
<>
{/* Mobile hamburger */}
<button
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
style={{
backgroundColor: "var(--background)",
border: "1.5px solid var(--border)",
color: "var(--foreground)",
}}
onClick={() => setOpen((v) => !v)}
aria-label="Навигация по курсу"
>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay */}
{open && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-30"
onClick={() => setOpen(false)}
/>
)}
<aside
className={[
"w-64 flex flex-col overflow-y-auto",
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
"lg:sticky lg:shrink-0 lg:translate-x-0",
open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
].join(" ")}
style={{
borderRight: "2px solid var(--border)",
backgroundColor: "var(--background)",
maxHeight: "calc(100vh - 53px)",
top: "53px",
}}
>
{/* Course title + progress */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link
href={`/courses/${course.slug}`}
onClick={onNavigate}
className="font-bold text-sm leading-snug block mb-1"
style={{ color: "var(--foreground)" }}
>
@@ -87,6 +59,7 @@ export function CourseSidebar({
</Link>
<Link
href="/dashboard"
onClick={onNavigate}
className="text-xs block underline mb-3"
style={{ color: "var(--muted-foreground)" }}
>
@@ -100,7 +73,13 @@ export function CourseSidebar({
{completedCount} из {totalLessons} уроков
</span>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
{progressPct}%
{/* The page's single animated moment (DS-2 §8); static under reduced motion. */}
<NumberFlow
value={progressPct}
suffix="%"
animated={animateProgress}
respectMotionPreference
/>
</span>
</div>
<div
@@ -108,7 +87,7 @@ export function CourseSidebar({
style={{ background: "var(--border)" }}
>
<div
className="h-full transition-all duration-300"
className="h-full transition-all duration-300 motion-reduce:transition-none"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
@@ -146,7 +125,7 @@ export function CourseSidebar({
<Link
key={lesson.id}
href={`/courses/${course.slug}/lessons/${lesson.id}`}
onClick={() => setOpen(false)}
onClick={() => onNavigate()}
className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{
borderLeftColor: active ? "var(--foreground)" : "transparent",
@@ -173,7 +152,84 @@ export function CourseSidebar({
);
})}
</nav>
</>
);
}
export function CourseSidebar({
course,
completedLessonIds = new Set(),
}: {
course: Course;
completedLessonIds?: Set<string>;
}) {
const pathname = usePathname();
// Tablet (768–1023px): the old off-canvas panel. Phone (<768px): bottom sheet.
const [open, setOpen] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const isMobile = useIsMobile();
const menuOpen = isMobile ? sheetOpen : open;
return (
<>
{/* Mobile hamburger */}
<button
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
style={{
backgroundColor: "var(--background)",
border: "1.5px solid var(--border)",
color: "var(--foreground)",
}}
onClick={() => (isMobile ? setSheetOpen((v) => !v) : setOpen((v) => !v))}
aria-label="Навигация по курсу"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Overlay */}
{open && !isMobile && (
<div
className="lg:hidden fixed inset-0 bg-black/50 z-30"
onClick={() => setOpen(false)}
/>
)}
<aside
className={[
"w-64 flex flex-col overflow-y-auto",
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
"lg:sticky lg:shrink-0 lg:translate-x-0",
open && !isMobile ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
].join(" ")}
style={{
borderRight: "2px solid var(--border)",
backgroundColor: "var(--background)",
maxHeight: "calc(100vh - 53px)",
top: "53px",
}}
>
<CourseNavBody
course={course}
completedLessonIds={completedLessonIds}
pathname={pathname}
onNavigate={() => setOpen(false)}
animateProgress={!isMobile}
/>
</aside>
{isMobile && (
<AubadeDrawer open={sheetOpen} onOpenChange={setSheetOpen} className="px-0 pt-0">
<ResponsiveDialogTitle className="sr-only">Навигация по курсу</ResponsiveDialogTitle>
<CourseNavBody
course={course}
completedLessonIds={completedLessonIds}
pathname={pathname}
onNavigate={() => setSheetOpen(false)}
animateProgress
/>
</AubadeDrawer>
)}
</>
);
}
+15 -74
View File
@@ -1,11 +1,16 @@
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { useEffect, useId, useState, type CSSProperties } from "react";
import {
PAY_METHODS_URL,
PAY_ORDER_URL,
type StoreCatalogEntry,
} from "@/lib/store-catalog";
import {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogTitle,
} from "@/components/shared/responsive-dialog";
export interface LockedCourse {
id: string;
@@ -50,7 +55,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
const [method, setMethod] = useState("robokassa");
const [otherOpen, setOtherOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
// Kept open locally so the phone sheet can finish its close animation
// before the parent unmounts us via onClose.
const [open, setOpen] = useState(true);
const titleId = useId();
const multiTariff = entry.tariffs.length > 1;
@@ -71,31 +78,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
.catch(() => {});
}, []);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// Lock body scroll while the modal is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
// Focus the dialog on open, restore focus on close
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => prev?.focus();
}, []);
// Coming back from the payment page via bfcache must not leave the
// submit button stuck in the "submitting" state.
useEffect(() => {
@@ -106,50 +88,12 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
return () => window.removeEventListener("pageshow", onPageShow);
}, []);
// Minimal focus trap: keep Tab cycling inside the dialog
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
const labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
const primary = methods?.[0];
const rest = methods?.slice(1) ?? [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: "rgba(50,50,50,0.45)" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="w-full max-w-md max-h-[90dvh] overflow-y-auto p-6 space-y-4"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
<ResponsiveDialog open={open} onOpenChange={setOpen} onClosed={onClose} className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<p
@@ -158,15 +102,13 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
>
Покупка курса
</p>
<button
type="button"
onClick={onClose}
<ResponsiveDialogClose
aria-label="Закрыть"
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
>
×
</button>
</ResponsiveDialogClose>
</div>
<div className="flex items-start gap-3">
@@ -186,9 +128,9 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
📚
</div>
)}
<h2 id={titleId} className="font-bold text-lg leading-tight">
<ResponsiveDialogTitle className="font-bold text-lg leading-tight">
{course.title}
</h2>
</ResponsiveDialogTitle>
</div>
{course.description && (
@@ -366,7 +308,6 @@ export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
после оплаты.
</p>
</div>
</div>
</div>
</ResponsiveDialog>
);
}
+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);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { accessWhere, isAuthorized, parseAccessCheck } from "../access-check";
describe("access-check", () => {
it("нормализует почту и требует оба поля", () => {
expect(parseAccessCheck({ email: " A@Example.COM ", course_slug: "second-brain-vault" }))
.toEqual({ email: "a@example.com", courseSlug: "second-brain-vault" });
expect(parseAccessCheck({ email: "a@example.com" })).toBeNull();
expect(parseAccessCheck({ course_slug: "x" })).toBeNull();
expect(parseAccessCheck({ email: "без-собаки", course_slug: "x" })).toBeNull();
expect(parseAccessCheck(null)).toBeNull();
expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull();
});
it("условие: точная почта (без ILIKE-шаблонов), не заблокирован, курс по slug, доступ не истёк", () => {
const now = new Date("2026-10-13T00:00:00Z");
const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now);
expect(w.user.email).toBe("a@example.com");
expect(JSON.stringify(w)).not.toContain("insensitive");
expect(w.user.NOT).toEqual({ banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] });
expect(w.course).toEqual({ slug: "second-brain-vault" });
expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]);
});
it("без секрета или с чужим — отказ", () => {
expect(isAuthorized("s", "s")).toBe(true);
expect(isAuthorized("s", undefined)).toBe(false);
expect(isAuthorized("s", "")).toBe(false);
expect(isAuthorized(null, "s")).toBe(false);
expect(isAuthorized("x", "s")).toBe(false);
expect(isAuthorized("ss", "s")).toBe(false);
});
});
@@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => {
expect(html).not.toContain("Отвечает <strong>");
expect(html).toContain("Привет!");
});
it("делает ссылки в тексте ответа кликабельными", async () => {
await sendCommentReplyEmail(
"student@example.com",
"Екатерина",
"6.3. Холст",
"Подарок тут: https://obsidian.second-brain.ru/secret-bonus?a=1&b=2. Пишите!",
LESSON_URL
);
const html = sent[0].html;
expect(html).toContain(
'<a href="https://obsidian.second-brain.ru/secret-bonus?a=1&amp;b=2" target="_blank"'
);
expect(html).toContain("secret-bonus?a=1&amp;b=2</a>. Пишите!");
});
});
+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");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { cleanUtm, parseUtmCookie, pickUtm } from "../utm-cookie";
describe("utm-cookie", () => {
it("разбирает сырую строку запроса из cookie", () => {
expect(parseUtmCookie("utm_source=youtube&utm_medium=video&utm_campaign=test-20261103&fbclid=x")).toEqual({
utmSource: "youtube",
utmMedium: "video",
utmCampaign: "test-20261103",
});
});
it("понимает percent-кодирование", () => {
expect(parseUtmCookie("utm_campaign=a%2Db_c.d").utmCampaign).toBe("a-b_c.d");
});
it("пустая и битая cookie — пустые метки", () => {
expect(parseUtmCookie(undefined)).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
expect(parseUtmCookie("")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
expect(parseUtmCookie("%%%")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
});
it("отбрасывает значения не из белого списка символов и слишком длинные", () => {
expect(cleanUtm("ok-1")).toBe("ok-1");
expect(cleanUtm("<script>")).toBe("");
expect(cleanUtm("с пробелом")).toBe("");
expect(cleanUtm("a".repeat(150))).toBe("a".repeat(100));
expect(parseUtmCookie("utm_source=x;Domain=evil").utmSource).toBe("");
});
it("метки из адреса важнее cookie, поля не смешиваются", () => {
const cookie = { utmSource: "vk", utmMedium: "video", utmCampaign: "old" };
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "new" }, cookie)).toEqual({
utmSource: "",
utmMedium: "",
utmCampaign: "new",
});
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "" }, cookie)).toEqual(cookie);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Проверка «у этой почты есть действующий доступ к курсу» для внутренних
* вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель
* Second Brain Vault оставляет почту, бот получает ключ входа, только если
* доступ к курсу действительно есть.
*
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
*/
import { timingSafeEqual } from "crypto";
export type AccessCheckInput = { email: string; courseSlug: string };
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : "";
if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) {
return null;
}
return { email, courseSlug };
}
/**
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
* пользователя с ЭТОЙ почтой.
*
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
*/
export function accessWhere(input: AccessCheckInput, now: Date) {
return {
user: {
email: input.email,
NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] },
},
course: { slug: input.courseSlug },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
if (!secret || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}
+15 -1
View File
@@ -1,5 +1,6 @@
import { Resend } from "resend";
import { getSetting } from "./settings";
import { URL_RE } from "./linkify";
function getResend() {
return new Resend(process.env.RESEND_API_KEY);
@@ -97,11 +98,24 @@ function escapeHtml(text: string) {
.replace(/"/g, "&quot;");
}
// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку.
// Экранируем по частям, чтобы `&` в адресе не превратился в `&amp;` внутри href дважды.
function linkifyHtml(text: string) {
return text
.split(URL_RE)
.map((part, i) =>
i % 2 === 1
? `<a href="${escapeHtml(part)}" target="_blank" style="color:#323232;text-decoration:underline;word-break:break-all;">${escapeHtml(part)}</a>`
: escapeHtml(part)
)
.join("");
}
function quote(text: string) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
<tr>
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p>
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${linkifyHtml(text).replace(/\n/g, "<br/>")}</p>
</td>
</tr>
</table>`;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Fragment, type ReactNode } from "react";
const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
export const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
export function linkify(text: string): ReactNode[] {
if (!text) return [];
+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);
}
}
}
+10 -3
View File
@@ -5,6 +5,8 @@
// price here is a display bug, not a money bug. When a price changes:
// products.yaml → landing → this file.
import { resolvePayBase } from "@/lib/pay-base";
export interface StoreTariff {
sku: string; // must match payment-router/products.yaml
label: string;
@@ -17,8 +19,13 @@ export interface StoreCatalogEntry {
tariffs: StoreTariff[];
}
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order";
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
// Literal process.env.* reads so Next inlines them into the client bundle.
const PAY_BASE = resolvePayBase({
payBaseUrl: process.env.NEXT_PUBLIC_PAY_BASE_URL,
nodeEnv: process.env.NODE_ENV,
});
export const PAY_ORDER_URL = `${PAY_BASE}/pay/order`;
export const PAY_METHODS_URL = `${PAY_BASE}/pay/methods`;
// Keys = course slugs shown as locked cards on the student dashboard.
// Only tariffs of the course's "native" landing are listed — cross-bundles
@@ -26,7 +33,7 @@ export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
export const STORE_CATALOG: Record<string, StoreCatalogEntry> = {
"second-brain-vault": {
landingUrl: "https://second-brain.ru/sb-vault",
tariffs: [{ sku: "sb-vault", label: "Second Brain Vault", priceRub: 2990 }],
tariffs: [{ sku: "sb-vault", label: "Second Brain Vault", priceRub: 4990 }],
},
"claude-obsidian": {
landingUrl: "https://second-brain.ru/claude-obsidian",
+48
View File
@@ -0,0 +1,48 @@
// Метки utm с лендингов (20261103, задание CMO «utm_campaign в LMS»).
//
// Лендинги второго мозга — поддомены second-brain.ru за общим Caddy на проде.
// Caddy при заходе с utm_* в адресе кладёт сырую строку запроса в cookie
// `sb_utm` на домен .second-brain.ru (см. фрагмент `(sb_utm)` в prod.caddy).
// Кнопка лендинга ведёт на /register без меток, поэтому страница регистрации
// берёт их из адреса, а если там пусто — из этой cookie.
export const UTM_COOKIE = "sb_utm";
export type Utm = { utmSource: string; utmMedium: string; utmCampaign: string };
const EMPTY: Utm = { utmSource: "", utmMedium: "", utmCampaign: "" };
// Значение метки: только латиница, цифры, точка, дефис, подчёркивание; до 100 символов.
// Остальное (кириллица, пробелы, кавычки) отбрасываем целиком — в отчёт идут слаги.
export function cleanUtm(v: string | null | undefined): string {
if (!v) return "";
const s = v.trim().slice(0, 100);
return /^[A-Za-z0-9._-]+$/.test(s) ? s : "";
}
export function parseUtmCookie(raw: string | null | undefined): Utm {
if (!raw) return EMPTY;
let q: URLSearchParams;
try {
q = new URLSearchParams(raw.length > 2000 ? raw.slice(0, 2000) : raw);
} catch {
return EMPTY;
}
return {
utmSource: cleanUtm(q.get("utm_source")),
utmMedium: cleanUtm(q.get("utm_medium")),
utmCampaign: cleanUtm(q.get("utm_campaign")),
};
}
// Метки из адреса важнее cookie: прямой переход на /register?utm_… — самый свежий.
// Берём набор целиком из одного источника, не смешиваем поля.
export function pickUtm(fromQuery: Utm, fromCookie: Utm): Utm {
const q = {
utmSource: cleanUtm(fromQuery.utmSource),
utmMedium: cleanUtm(fromQuery.utmMedium),
utmCampaign: cleanUtm(fromQuery.utmCampaign),
};
if (q.utmSource || q.utmMedium || q.utmCampaign) return q;
return fromCookie;
}