From c64f393a7b19bd410ee0fca6c8a8d4a031b7a919 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 27 Apr 2026 15:31:10 +0500 Subject: [PATCH] Implement platform settings (Stage 9) - Wire settings to actual platform behavior: maintenance mode, registration toggle, notification emails, curator feedback emails, email verification flag - Add logo (logoUrl, showLogo) and social network links (YouTube, VK, Telegram) settings - Show logo + school name dynamically in student layout header - Add footer to student layout with org requisites and social links - Register page: read settings server-side, validate terms checkbox with legal links - Login page: show notice when redirected from closed registration - Settings form: add Logo and Social Networks sections Co-Authored-By: Claude Sonnet 4.6 --- src/app/(auth)/login/page.tsx | 22 +++++++- src/app/(auth)/register/page.tsx | 19 ++++++- src/app/(auth)/register/register-form.tsx | 52 ++++++++++++++++- src/app/(student)/layout.tsx | 48 +++++++++++++++- .../homework/[submissionId]/actions.ts | 20 ++++--- src/components/admin/settings-form.tsx | 56 +++++++++++++++++++ src/lib/actions/student-actions.ts | 21 ++++--- src/lib/settings.ts | 9 +++ 8 files changed, 223 insertions(+), 24 deletions(-) diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index cf58465..1686908 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,17 +1,35 @@ +import { getSetting } from "@/lib/settings"; import { LoginForm } from "./login-form"; -export default function LoginPage() { +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise<{ notice?: string }>; +}) { + const [schoolName, { notice }] = await Promise.all([ + getSetting("schoolName"), + searchParams, + ]); + return (

- Second Brain + {schoolName}

Образовательная платформа

+ {notice === "registration_closed" && ( +
+ Регистрация временно закрыта. Обратитесь к администратору. +
+ )}
diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 3bdf363..c4e9088 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,19 +1,32 @@ +import { redirect } from "next/navigation"; +import { getSettings } from "@/lib/settings"; import { RegisterForm } from "./register-form"; -export default function RegisterPage() { +export default async function RegisterPage() { + const settings = await getSettings(); + + if (settings.registrationEnabled !== "true") { + redirect("/login?notice=registration_closed"); + } + return (

- Second Brain + {settings.schoolName}

Образовательная платформа

- +
diff --git a/src/app/(auth)/register/register-form.tsx b/src/app/(auth)/register/register-form.tsx index 31365ed..a96cb09 100644 --- a/src/app/(auth)/register/register-form.tsx +++ b/src/app/(auth)/register/register-form.tsx @@ -4,10 +4,18 @@ import { useState } from "react"; import Link from "next/link"; import { signUp } from "@/lib/auth-client"; -export function RegisterForm() { +interface Props { + showTermsCheckbox: boolean; + privacyPolicyUrl: string; + termsUrl: string; + offerUrl: string; +} + +export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [termsAccepted, setTermsAccepted] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); @@ -22,8 +30,18 @@ export function RegisterForm() { fontFamily: "inherit", } as React.CSSProperties; + const legalLinks = [ + { url: privacyPolicyUrl, label: "Политику конфиденциальности" }, + { url: termsUrl, label: "Согласие на обработку данных" }, + { url: offerUrl, label: "Договор-оферту" }, + ].filter((l) => l.url); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + if (showTermsCheckbox && !termsAccepted) { + setError("Необходимо принять условия для продолжения"); + return; + } setError(""); setLoading(true); @@ -102,6 +120,38 @@ export function RegisterForm() { placeholder="Минимум 8 символов" />
+ + {showTermsCheckbox && ( + + )} + {error && (

{error} diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx index 4860840..52aa8c7 100644 --- a/src/app/(student)/layout.tsx +++ b/src/app/(student)/layout.tsx @@ -16,6 +16,17 @@ export default async function StudentLayout({ children }: { children: React.Reac if (maintenance === "true") redirect("/maintenance"); } + const [schoolName, logoUrl, showLogo, socialYoutube, socialVk, socialTelegram, orgRequisites] = + await Promise.all([ + getSetting("schoolName"), + getSetting("logoUrl"), + getSetting("showLogo"), + getSetting("socialYoutube"), + getSetting("socialVk"), + getSetting("socialTelegram"), + getSetting("orgRequisites"), + ]); + const isImpersonating = !!(session.session as { impersonatedBy?: string }).impersonatedBy; return ( @@ -25,8 +36,12 @@ export default async function StudentLayout({ children }: { children: React.Reac className="sticky top-0 z-10 flex items-center justify-between px-6 py-3" style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }} > - - Second Brain + + {logoUrl && showLogo === "true" && ( + // eslint-disable-next-line @next/next/no-img-element + {schoolName} + )} + {schoolName}

{session.user.name} @@ -34,6 +49,35 @@ export default async function StudentLayout({ children }: { children: React.Reac
{children}
+ {(socialYoutube || socialVk || socialTelegram || orgRequisites) && ( +
+ {orgRequisites && ( +

{orgRequisites}

+ )} + {(socialYoutube || socialVk || socialTelegram) && ( +
+ {socialYoutube && ( + + YouTube + + )} + {socialVk && ( + + VK + + )} + {socialTelegram && ( + + Telegram + + )} +
+ )} +
+ )}
); } diff --git a/src/app/curator/homework/[submissionId]/actions.ts b/src/app/curator/homework/[submissionId]/actions.ts index a305e14..cb2dfe2 100644 --- a/src/app/curator/homework/[submissionId]/actions.ts +++ b/src/app/curator/homework/[submissionId]/actions.ts @@ -5,6 +5,7 @@ import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { revalidatePath } from "next/cache"; import { sendFeedbackReceivedEmail } from "@/lib/email"; +import { getSetting, asBool } from "@/lib/settings"; async function requireCurator() { const session = await auth.api.getSession({ headers: await headers() }); @@ -78,14 +79,17 @@ export async function submitFeedback( }); const { lesson } = submission.homework; - const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`; - await sendFeedbackReceivedEmail( - submission.user.email, - submission.user.name, - lesson.title, - data.text, - lessonUrl - ); + const notifySetting = await getSetting("notifyStudentOnFeedback"); + if (asBool(notifySetting)) { + const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`; + await sendFeedbackReceivedEmail( + submission.user.email, + submission.user.name, + lesson.title, + data.text, + lessonUrl + ); + } revalidatePath("/curator/homework"); revalidatePath(`/curator/homework/${submissionId}`); diff --git a/src/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx index 7eb115e..b69b1db 100644 --- a/src/components/admin/settings-form.tsx +++ b/src/components/admin/settings-form.tsx @@ -432,6 +432,62 @@ export function SettingsForm({ initial }: { initial: Settings }) { + {/* ── 7. Логотип ── */} +
+ + set("logoUrl", e.target.value)} + placeholder="https://..." + style={{ ...inputStyle, fontFamily: "var(--font-mono)" }} + {...focusHandlers} + /> + + set("showLogo", v ? "true" : "false")} + /> +
+ + {/* ── 8. Социальные сети ── */} +
+ + set("socialYoutube", e.target.value)} + placeholder="https://youtube.com/@..." + style={{ ...inputStyle, fontFamily: "var(--font-mono)" }} + {...focusHandlers} + /> + + + set("socialVk", e.target.value)} + placeholder="https://vk.com/..." + style={{ ...inputStyle, fontFamily: "var(--font-mono)" }} + {...focusHandlers} + /> + + + set("socialTelegram", e.target.value)} + placeholder="https://t.me/..." + style={{ ...inputStyle, fontFamily: "var(--font-mono)" }} + {...focusHandlers} + /> + +
+ {/* Bottom save button */}