diff --git a/.env.example b/.env.example index 3a14117..fa1fc1b 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,7 @@ LISTMONK_URL="https://newsletter.second-brain.ru" LISTMONK_USER="apiuser" LISTMONK_TOKEN="" LISTMONK_QUIZ_LIST_ID="" + +# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения +# показываем лестницу-оффер (трипвайр + полный курс) +WOW_MOMENT_LESSON_ID="obs-start-l2-006" diff --git a/.superpowers/quiz-lead-task3-report.md b/.superpowers/quiz-lead-task3-report.md new file mode 100644 index 0000000..4589131 --- /dev/null +++ b/.superpowers/quiz-lead-task3-report.md @@ -0,0 +1,62 @@ +# Quiz-Lead Task 3 — Implementation Report + +Date: 20260622 + +## STATUS: DONE + +## Files created/modified + +| File | Action | +|------|--------| +| `src/app/api/internal/quiz-lead/route.ts` | CREATED | +| `.env.example` | MODIFIED — added QUIZ_LEAD_SECRET, LISTMONK_URL, LISTMONK_USER, LISTMONK_TOKEN, LISTMONK_QUIZ_LIST_ID | +| `src/middleware.ts` | NOT MODIFIED — `/api/internal` already in PUBLIC_ROUTES with startsWith match; covers /api/internal/quiz-lead without changes | + +## Test infra + +`grep -E 'vitest|jest' package.json` → no output (empty). +No test framework present. Verification done via `npx tsc --noEmit` + manual reasoning. + +## Commands and output + +### `npm run type-check` +``` +> lms-sb@0.1.0 type-check +> tsc --noEmit + +(no output = clean) +``` +Exit code: 0 + +### `npm run lint` (new file only) +``` +grep 'quiz-lead' in lint output → no results (no errors in new file) +``` + +Pre-existing lint errors in repo (36 total, 20 errors) are NOT related to this task: +- `.claude/plugins/superpowers/` scripts using CommonJS require +- `quick-enroll-modal.tsx` — react-hooks/immutability (pre-existing) +- `kinescope-player.tsx` — react-hooks/set-state-in-effect (pre-existing) + +## Commit + +Base: `9fbb7ae` +New: `5e65ad6` + +``` +5e65ad6 Add quiz-lead internal endpoint for Typebot → Listmonk funnel +``` + +## Implementation notes + +- Auth: `x-quiz-secret` header vs `QUIZ_LEAD_SECRET` env var; 401 on mismatch/missing +- Validation: email regex, archetype against const tuple, Number.isFinite(score); 400 on failure +- Upsert logic: POST create → 409 → GET by email SQL query → PUT update; 502 on Listmonk errors +- Middleware: `/api/internal` already in PUBLIC_ROUTES with `startsWith` — no changes needed +- TypeScript strict mode: no `any` used; typed interfaces for Listmonk response shapes +- No production Listmonk list created; LISTMONK_QUIZ_LIST_ID left as empty placeholder in .env.example +- No git push; no deploy + +## Concerns + +None. The pre-existing lint errors in the repo are unrelated to this task and were already present before this commit. diff --git a/prisma/migrations/20260623060000_add_user_archetype_utm/migration.sql b/prisma/migrations/20260623060000_add_user_archetype_utm/migration.sql new file mode 100644 index 0000000..16d48cf --- /dev/null +++ b/prisma/migrations/20260623060000_add_user_archetype_utm/migration.sql @@ -0,0 +1,5 @@ +-- Тип второго мозга из freemium-квиза + UTM-метки для персонализации воронки +ALTER TABLE "User" ADD COLUMN "archetype" TEXT; +ALTER TABLE "User" ADD COLUMN "utmSource" TEXT; +ALTER TABLE "User" ADD COLUMN "utmMedium" TEXT; +ALTER TABLE "User" ADD COLUMN "utmCampaign" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b1e16b9..1bea6cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,10 @@ model User { banExpires DateTime? comment String? mustChangePassword Boolean @default(false) + archetype String? // тип второго мозга из квиза: architect|gardener|librarian|pragmatist + utmSource String? + utmMedium String? + utmCampaign String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index f5e24a2..1588585 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -2,13 +2,20 @@ import { redirect } from "next/navigation"; import { getSettings } from "@/lib/settings"; import { RegisterForm } from "./register-form"; -export default async function RegisterPage() { +export default async function RegisterPage({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { const settings = await getSettings(); if (settings.registrationEnabled !== "true") { redirect("/login?notice=registration_closed"); } + const sp = await searchParams; + const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? ""; + return (
📚
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index c03adfd..ffd1f34 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { return jsonError("Неверный формат запроса", 400); } - const { name, email, password, website, cfTurnstileResponse } = body; + const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign } = body; // Honeypot — bots fill this field, humans don't see it if (website) { @@ -102,6 +102,19 @@ export async function POST(request: NextRequest) { select: { id: true }, }), ]); + // Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации + const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"]; + if (user && (archetype || utmSource || utmMedium || utmCampaign)) { + await prisma.user.update({ + where: { id: user.id }, + data: { + archetype: VALID_ARCHETYPES.includes(String(archetype)) ? String(archetype) : null, + utmSource: utmSource ? String(utmSource) : null, + utmMedium: utmMedium ? String(utmMedium) : null, + utmCampaign: utmCampaign ? String(utmCampaign) : null, + }, + }); + } if (user && course) { await prisma.courseEnrollment.upsert({ where: { userId_courseId: { userId: user.id, courseId: course.id } }, diff --git a/src/components/student/archetype-banner.tsx b/src/components/student/archetype-banner.tsx new file mode 100644 index 0000000..03e9e77 --- /dev/null +++ b/src/components/student/archetype-banner.tsx @@ -0,0 +1,68 @@ +// Персональный блок «твой путь» по типу второго мозга из квиза. +// Server component, без клиентского кода. ДС-2 (Aubade). + +const TRIPWIRE_URL = "https://second-brain.ru/entrepreneur"; + +const ARCHETYPE_INFO: Record< + string, + { emoji: string; name: string; desc: string; cta: string; href: string } +> = { + architect: { + emoji: "🏛", + name: "Архитектор", + desc: "Ты мыслишь системами: структура, иерархия, порядок. Тебе подойдёт системный путь.", + cta: "Полный курс «Obsidian»", + href: "https://obsidian.second-brain.ru/", + }, + gardener: { + emoji: "🌱", + name: "Садовник", + desc: "Ты выращиваешь идеи и связи. Тебе подойдёт работа с мышлением и AI.", + cta: "Курс «Obsidian + AI»", + href: "https://obsidianai.second-brain.ru/", + }, + librarian: { + emoji: "📚", + name: "Библиотекарь", + desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.", + cta: "Демо-хранилище", + href: "https://second-brain.ru/sb-vault", + }, + pragmatist: { + emoji: "⚡", + name: "Прагматик", + desc: "Тебе важен результат: заметки работают на дело, а не ради заметок.", + cta: "Второй мозг для предпринимателя", + href: TRIPWIRE_URL, + }, +}; + +export function ArchetypeBanner({ archetype }: { archetype: string }) { + const info = ARCHETYPE_INFO[archetype]; + if (!info) return null; + + return ( ++ Твой тип второго мозга +
++ {info.desc} Начни с бесплатного курса «Obsidian. Старт» ниже — а когда захочешь дальше, тебе откроется: +
+ + {info.cta} → + ++ Ты освоил главную привычку +
++ Ежедневные заметки — это фундамент второго мозга. Дальше система начинает + работать на тебя. Выбери свой следующий шаг: +
+ +