Add freemium variant C: archetype persistence, dashboard banner, lesson gate
- User.archetype/utmSource/utmMedium/utmCampaign fields + migration - register flow reads ?archetype/?utm_* and saves them on the User row - dashboard ArchetypeBanner: per-archetype 'твой путь' block - lesson gate after wow-moment lesson (WOW_MOMENT_LESSON_ID=obs-start-l2-006) → tripwire + full-course offer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,3 +32,7 @@ LISTMONK_URL="https://newsletter.second-brain.ru"
|
|||||||
LISTMONK_USER="apiuser"
|
LISTMONK_USER="apiuser"
|
||||||
LISTMONK_TOKEN=""
|
LISTMONK_TOKEN=""
|
||||||
LISTMONK_QUIZ_LIST_ID=""
|
LISTMONK_QUIZ_LIST_ID=""
|
||||||
|
|
||||||
|
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
|
||||||
|
# показываем лестницу-оффер (трипвайр + полный курс)
|
||||||
|
WOW_MOMENT_LESSON_ID="obs-start-l2-006"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -25,6 +25,10 @@ model User {
|
|||||||
banExpires DateTime?
|
banExpires DateTime?
|
||||||
comment String?
|
comment String?
|
||||||
mustChangePassword Boolean @default(false)
|
mustChangePassword Boolean @default(false)
|
||||||
|
archetype String? // тип второго мозга из квиза: architect|gardener|librarian|pragmatist
|
||||||
|
utmSource String?
|
||||||
|
utmMedium String?
|
||||||
|
utmCampaign String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ import { redirect } from "next/navigation";
|
|||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { RegisterForm } from "./register-form";
|
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();
|
const settings = await getSettings();
|
||||||
|
|
||||||
if (settings.registrationEnabled !== "true") {
|
if (settings.registrationEnabled !== "true") {
|
||||||
redirect("/login?notice=registration_closed");
|
redirect("/login?notice=registration_closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
@@ -26,6 +33,10 @@ export default async function RegisterPage() {
|
|||||||
privacyPolicyUrl={settings.privacyPolicyUrl}
|
privacyPolicyUrl={settings.privacyPolicyUrl}
|
||||||
termsUrl={settings.termsUrl}
|
termsUrl={settings.termsUrl}
|
||||||
offerUrl={settings.offerUrl}
|
offerUrl={settings.offerUrl}
|
||||||
|
archetype={str(sp.archetype)}
|
||||||
|
utmSource={str(sp.utm_source)}
|
||||||
|
utmMedium={str(sp.utm_medium)}
|
||||||
|
utmCampaign={str(sp.utm_campaign)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ interface Props {
|
|||||||
privacyPolicyUrl: string;
|
privacyPolicyUrl: string;
|
||||||
termsUrl: string;
|
termsUrl: string;
|
||||||
offerUrl: 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 [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -118,6 +122,10 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
|
|||||||
password,
|
password,
|
||||||
website: honeypot,
|
website: honeypot,
|
||||||
cfTurnstileResponse: turnstileToken,
|
cfTurnstileResponse: turnstileToken,
|
||||||
|
archetype,
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { HomeworkSection } from "@/components/student/homework-section";
|
|||||||
import { QuizSection } from "@/components/student/quiz-section";
|
import { QuizSection } from "@/components/student/quiz-section";
|
||||||
import { LessonComments } from "@/components/student/lesson-comments";
|
import { LessonComments } from "@/components/student/lesson-comments";
|
||||||
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
||||||
|
import { TripwireGateBanner } from "@/components/student/tripwire-gate-banner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string; lessonId: string }>;
|
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();
|
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
||||||
|
|
||||||
const isCompleted = !!progress;
|
const isCompleted = !!progress;
|
||||||
|
const showTripwireGate =
|
||||||
|
!isAdmin && isCompleted && lessonId === process.env.WOW_MOMENT_LESSON_ID;
|
||||||
|
|
||||||
// Build ordered flat list of all lessons for prev/next
|
// Build ordered flat list of all lessons for prev/next
|
||||||
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
|
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
|
||||||
@@ -267,6 +270,8 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showTripwireGate && <TripwireGateBanner />}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
{session && (
|
{session && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ArchetypeBanner } from "@/components/student/archetype-banner";
|
||||||
|
|
||||||
// Продаваемая линейка курсов → лендинг продукта. Курсы из этого списка,
|
// Продаваемая линейка курсов → лендинг продукта. Курсы из этого списка,
|
||||||
// которых нет у студента, показываются на дашборде заблюренными карточками
|
// которых нет у студента, показываются на дашборде заблюренными карточками
|
||||||
@@ -19,6 +20,11 @@ export default async function StudentDashboard() {
|
|||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { archetype: true },
|
||||||
|
});
|
||||||
|
|
||||||
const enrollments = await prisma.courseEnrollment.findMany({
|
const enrollments = await prisma.courseEnrollment.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
include: {
|
include: {
|
||||||
@@ -79,6 +85,8 @@ export default async function StudentDashboard() {
|
|||||||
{active.length} активных курсов
|
{active.length} активных курсов
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{dbUser?.archetype && <ArchetypeBanner archetype={dbUser.archetype} />}
|
||||||
|
|
||||||
{active.length === 0 ? (
|
{active.length === 0 ? (
|
||||||
<div className="card-aubade p-12 text-center">
|
<div className="card-aubade p-12 text-center">
|
||||||
<p className="text-4xl mb-3">📚</p>
|
<p className="text-4xl mb-3">📚</p>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return jsonError("Неверный формат запроса", 400);
|
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
|
// Honeypot — bots fill this field, humans don't see it
|
||||||
if (website) {
|
if (website) {
|
||||||
@@ -102,6 +102,19 @@ export async function POST(request: NextRequest) {
|
|||||||
select: { id: true },
|
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) {
|
if (user && course) {
|
||||||
await prisma.courseEnrollment.upsert({
|
await prisma.courseEnrollment.upsert({
|
||||||
where: { userId_courseId: { userId: user.id, courseId: course.id } },
|
where: { userId_courseId: { userId: user.id, courseId: course.id } },
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="card-aubade p-5 mb-8" style={{ background: "var(--accent)" }}>
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-1"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Твой тип второго мозга
|
||||||
|
</p>
|
||||||
|
<h2 className="font-bold text-lg mb-1">
|
||||||
|
{info.emoji} {info.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-3" style={{ color: "var(--foreground)" }}>
|
||||||
|
{info.desc} Начни с бесплатного курса «Obsidian. Старт» ниже — а когда захочешь дальше, тебе откроется:
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={info.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-aubade text-sm inline-block"
|
||||||
|
>
|
||||||
|
{info.cta} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="card-aubade p-6 mt-10"
|
||||||
|
style={{ background: "var(--accent)", boxShadow: "4px 4px 0 0 var(--foreground)" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Ты освоил главную привычку
|
||||||
|
</p>
|
||||||
|
<h2 className="font-bold text-xl mb-2">🎯 Что дальше?</h2>
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--foreground)" }}>
|
||||||
|
Ежедневные заметки — это фундамент второго мозга. Дальше система начинает
|
||||||
|
работать на тебя. Выбери свой следующий шаг:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<a
|
||||||
|
href={TRIPWIRE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm flex-1 text-center font-bold"
|
||||||
|
>
|
||||||
|
⚡ Второй мозг для предпринимателя →
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={FULL_COURSE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-aubade text-sm flex-1 text-center"
|
||||||
|
>
|
||||||
|
🏛 Полный курс «Obsidian» →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,11 @@ export const auth = betterAuth({
|
|||||||
defaultValue: "student",
|
defaultValue: "student",
|
||||||
input: false,
|
input: false,
|
||||||
},
|
},
|
||||||
|
archetype: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
input: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user