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 (
@@ -26,6 +33,10 @@ export default async function RegisterPage() { privacyPolicyUrl={settings.privacyPolicyUrl} 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)} />
diff --git a/src/app/(auth)/register/register-form.tsx b/src/app/(auth)/register/register-form.tsx index f6c5b38..45a2fce 100644 --- a/src/app/(auth)/register/register-form.tsx +++ b/src/app/(auth)/register/register-form.tsx @@ -25,9 +25,13 @@ interface Props { privacyPolicyUrl: string; termsUrl: string; offerUrl: string; + archetype?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; } -export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) { +export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign }: Props) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -118,6 +122,10 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of password, website: honeypot, cfTurnstileResponse: turnstileToken, + archetype, + utmSource, + utmMedium, + utmCampaign, }), }); } catch { diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index e18ebf7..34ff983 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -10,6 +10,7 @@ import { HomeworkSection } from "@/components/student/homework-section"; import { QuizSection } from "@/components/student/quiz-section"; import { LessonComments } from "@/components/student/lesson-comments"; import { FileFormatBadge } from "@/components/shared/file-format-badge"; +import { TripwireGateBanner } from "@/components/student/tripwire-gate-banner"; interface Props { params: Promise<{ slug: string; lessonId: string }>; @@ -90,6 +91,8 @@ export default async function LessonPage({ params }: Props) { if (!lesson || lesson.module.course.slug !== slug) notFound(); const isCompleted = !!progress; + const showTripwireGate = + !isAdmin && isCompleted && lessonId === process.env.WOW_MOMENT_LESSON_ID; // Build ordered flat list of all lessons for prev/next const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons); @@ -267,6 +270,8 @@ export default async function LessonPage({ params }: Props) { )} + {showTripwireGate && } + {/* Comments */} {session && (
+ {dbUser?.archetype && } + {active.length === 0 ? (

📚

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.emoji} {info.name} +

+

+ {info.desc} Начни с бесплатного курса «Obsidian. Старт» ниже — а когда захочешь дальше, тебе откроется: +

+ + {info.cta} → + +
+ ); +} diff --git a/src/components/student/tripwire-gate-banner.tsx b/src/components/student/tripwire-gate-banner.tsx new file mode 100644 index 0000000..61298f5 --- /dev/null +++ b/src/components/student/tripwire-gate-banner.tsx @@ -0,0 +1,45 @@ +// Gate-лестница после wow-урока (ежедневные заметки). Двойной оффер: +// основной — трипвайр «Второй мозг для предпринимателя», вторичный — полный курс. +// Server component, ДС-2 (Aubade). + +const TRIPWIRE_URL = "https://second-brain.ru/entrepreneur"; +const FULL_COURSE_URL = "https://obsidian.second-brain.ru/"; + +export function TripwireGateBanner() { + return ( +
+

+ Ты освоил главную привычку +

+

🎯 Что дальше?

+

+ Ежедневные заметки — это фундамент второго мозга. Дальше система начинает + работать на тебя. Выбери свой следующий шаг: +

+ +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8b26abf..b611717 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -71,6 +71,11 @@ export const auth = betterAuth({ defaultValue: "student", input: false, }, + archetype: { + type: "string", + required: false, + input: false, + }, }, }, });