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:
2026-06-23 11:26:34 +05:00
parent bb6c806cdd
commit 7bf3935c81
12 changed files with 241 additions and 3 deletions
+12 -1
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
@@ -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)}
/>
</div>
</div>
+9 -1
View File
@@ -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 {
@@ -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) {
)}
</div>
{showTripwireGate && <TripwireGateBanner />}
{/* Comments */}
{session && (
<div
+8
View File
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
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() });
if (!session) redirect("/login");
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { archetype: true },
});
const enrollments = await prisma.courseEnrollment.findMany({
where: { userId: session.user.id },
include: {
@@ -79,6 +85,8 @@ export default async function StudentDashboard() {
{active.length} активных курсов
</p>
{dbUser?.archetype && <ArchetypeBanner archetype={dbUser.archetype} />}
{active.length === 0 ? (
<div className="card-aubade p-12 text-center">
<p className="text-4xl mb-3">📚</p>
+14 -1
View File
@@ -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 } },
@@ -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>
);
}
+5
View File
@@ -71,6 +71,11 @@ export const auth = betterAuth({
defaultValue: "student",
input: false,
},
archetype: {
type: "string",
required: false,
input: false,
},
},
},
});