e77588deb8
- Settings key-value table in Prisma with migration - getSettings() / getSetting() helpers in lib/settings.ts - Admin UI at /admin/settings with 6 sections: General, Notifications, Student profile, Legal docs, Curator permissions, Code injection - saveSettings() server action with admin-only guard - Maintenance mode: non-admin users redirected to /maintenance page - schoolName propagated to page metadata and all email templates - headCode / bodyCode injected into root layout <head> and <body> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { Save } from "lucide-react";
|
|
import { saveSettings } from "@/app/admin/settings/actions";
|
|
import type { Settings } from "@/lib/settings";
|
|
|
|
// ── Small primitives ──────────────────────────────────────────────────────────
|
|
|
|
const inputStyle = {
|
|
border: "2px solid var(--border)",
|
|
background: "var(--background)",
|
|
outline: "none",
|
|
width: "100%",
|
|
padding: "0.5rem 0.75rem",
|
|
fontSize: "0.875rem",
|
|
fontFamily: "inherit",
|
|
} as React.CSSProperties;
|
|
|
|
const focusHandlers = {
|
|
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
|
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
|
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
|
(e.currentTarget.style.borderColor = "var(--border)"),
|
|
};
|
|
|
|
function Section({
|
|
title,
|
|
hint,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="card-aubade p-6 space-y-5">
|
|
<div>
|
|
<h2
|
|
className="text-xs font-bold uppercase tracking-widest"
|
|
style={{ color: "var(--muted-foreground)" }}
|
|
>
|
|
{title}
|
|
</h2>
|
|
{hint && (
|
|
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
|
{hint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<label
|
|
className="text-xs font-bold uppercase tracking-widest"
|
|
style={{ color: "var(--muted-foreground)" }}
|
|
>
|
|
{label}
|
|
</label>
|
|
{hint && (
|
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
{hint}
|
|
</p>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Toggle({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-start gap-3">
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className="mt-0.5 flex-shrink-0"
|
|
>
|
|
<span
|
|
className="relative inline-block w-10 h-6 transition-colors"
|
|
style={{
|
|
background: checked ? "var(--accent)" : "var(--border)",
|
|
border: "2px solid var(--foreground)",
|
|
}}
|
|
>
|
|
<span
|
|
className="absolute top-0.5 w-4 h-4 transition-transform"
|
|
style={{
|
|
background: "var(--foreground)",
|
|
left: "2px",
|
|
transform: checked ? "translateX(16px)" : "translateX(0)",
|
|
}}
|
|
/>
|
|
</span>
|
|
</button>
|
|
<div>
|
|
<p className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
|
|
{label}
|
|
</p>
|
|
{hint && (
|
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>
|
|
{hint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectField({
|
|
label,
|
|
hint,
|
|
value,
|
|
onChange,
|
|
options,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
options: { value: string; label: string }[];
|
|
}) {
|
|
return (
|
|
<Field label={label} hint={hint}>
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={{
|
|
...inputStyle,
|
|
appearance: "none",
|
|
cursor: "pointer",
|
|
}}
|
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
|
>
|
|
{options.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
// ── Main form ─────────────────────────────────────────────────────────────────
|
|
|
|
export function SettingsForm({ initial }: { initial: Settings }) {
|
|
const [s, setS] = useState(initial);
|
|
const [saved, setSaved] = useState(false);
|
|
const [pending, startTransition] = useTransition();
|
|
|
|
function set(key: keyof Settings, value: string) {
|
|
setS((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
function bool(key: keyof Settings) {
|
|
return s[key] === "true";
|
|
}
|
|
|
|
function handleSave() {
|
|
startTransition(async () => {
|
|
await saveSettings(s);
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2000);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Save button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={pending}
|
|
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
|
|
style={{ opacity: pending ? 0.6 : 1 }}
|
|
>
|
|
<Save size={14} />
|
|
{pending ? "Сохранение..." : saved ? "Сохранено ✓" : "Сохранить настройки"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── 1. Основное ── */}
|
|
<Section title="Основное">
|
|
<Field label="Название школы" hint="Отображается в заголовке браузера, письмах и подписях">
|
|
<input
|
|
value={s.schoolName}
|
|
onChange={(e) => set("schoolName", e.target.value)}
|
|
style={inputStyle}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<Field label="Описание школы" hint="Мета-тег description для поисковых систем">
|
|
<textarea
|
|
value={s.schoolDescription}
|
|
onChange={(e) => set("schoolDescription", e.target.value)}
|
|
rows={2}
|
|
style={{ ...inputStyle, resize: "vertical" }}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<Field label="Ключевые слова" hint="Мета-тег keywords, через запятую">
|
|
<input
|
|
value={s.schoolKeywords}
|
|
onChange={(e) => set("schoolKeywords", e.target.value)}
|
|
placeholder="obsidian, pkm, second brain"
|
|
style={inputStyle}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<div className="space-y-3 pt-1">
|
|
<Toggle
|
|
label="Режим технических работ"
|
|
hint="Ученики увидят страницу-заглушку. Администраторы входят в обычном режиме."
|
|
checked={bool("maintenanceMode")}
|
|
onChange={(v) => set("maintenanceMode", v ? "true" : "false")}
|
|
/>
|
|
<Toggle
|
|
label="Открытая регистрация"
|
|
hint="Если выключено — форма регистрации недоступна, новые аккаунты создаёт только администратор."
|
|
checked={bool("registrationEnabled")}
|
|
onChange={(v) => set("registrationEnabled", v ? "true" : "false")}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* ── 2. Уведомления ── */}
|
|
<Section
|
|
title="Уведомления"
|
|
hint="Кому отправлять системные письма о новых ДЗ, регистрациях и вопросах учеников."
|
|
>
|
|
<Field
|
|
label="Email(ы) для уведомлений"
|
|
hint="По одному адресу на строку. Если пусто — письма не отправляются."
|
|
>
|
|
<textarea
|
|
value={s.notificationEmails}
|
|
onChange={(e) => set("notificationEmails", e.target.value)}
|
|
rows={3}
|
|
placeholder={"admin@school.ru\ncurator@school.ru"}
|
|
style={{ ...inputStyle, resize: "vertical", fontFamily: "var(--font-mono)" }}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<div className="space-y-3 pt-1">
|
|
<Toggle
|
|
label="Уведомлять о новом домашнем задании"
|
|
checked={bool("notifyOnHomework")}
|
|
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
|
|
/>
|
|
<Toggle
|
|
label="Уведомлять о новой регистрации ученика"
|
|
checked={bool("notifyOnRegistration")}
|
|
onChange={(v) => set("notifyOnRegistration", v ? "true" : "false")}
|
|
/>
|
|
<Toggle
|
|
label="Уведомлять ученика о полученном фидбеке"
|
|
checked={bool("notifyStudentOnFeedback")}
|
|
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* ── 3. Данные ученика ── */}
|
|
<Section title="Данные ученика" hint="Поля при регистрации и требования к аккаунту.">
|
|
<Toggle
|
|
label="Требовать подтверждение email"
|
|
hint="Пока email не подтверждён — ученик не может войти в личный кабинет."
|
|
checked={bool("requireEmailVerification")}
|
|
onChange={(v) => set("requireEmailVerification", v ? "true" : "false")}
|
|
/>
|
|
<SelectField
|
|
label="Фамилия при регистрации"
|
|
value={s.lastNameField}
|
|
onChange={(v) => set("lastNameField", v)}
|
|
options={[
|
|
{ value: "required", label: "Обязательная" },
|
|
{ value: "optional", label: "Необязательная" },
|
|
{ value: "hidden", label: "Не показывать" },
|
|
]}
|
|
/>
|
|
<SelectField
|
|
label="Телефон при регистрации"
|
|
value={s.phoneField}
|
|
onChange={(v) => set("phoneField", v)}
|
|
options={[
|
|
{ value: "required", label: "Обязательный" },
|
|
{ value: "optional", label: "Необязательный" },
|
|
{ value: "hidden", label: "Не показывать" },
|
|
]}
|
|
/>
|
|
</Section>
|
|
|
|
{/* ── 4. Юридические документы ── */}
|
|
<Section
|
|
title="Юридические документы"
|
|
hint="Ссылки на внешние документы (Google Docs, Notion и т.п.)."
|
|
>
|
|
<Field label="Политика конфиденциальности (URL)">
|
|
<input
|
|
value={s.privacyPolicyUrl}
|
|
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
|
|
placeholder="https://..."
|
|
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<Field label="Согласие на обработку персональных данных (URL)">
|
|
<input
|
|
value={s.termsUrl}
|
|
onChange={(e) => set("termsUrl", e.target.value)}
|
|
placeholder="https://..."
|
|
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<Field label="Договор-оферта (URL)">
|
|
<input
|
|
value={s.offerUrl}
|
|
onChange={(e) => set("offerUrl", e.target.value)}
|
|
placeholder="https://..."
|
|
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<Toggle
|
|
label="Чекбокс «Я принимаю условия» на форме регистрации"
|
|
hint="Ученик обязан поставить галочку перед отправкой формы."
|
|
checked={bool("showTermsCheckbox")}
|
|
onChange={(v) => set("showTermsCheckbox", v ? "true" : "false")}
|
|
/>
|
|
<Field
|
|
label="Реквизиты организации"
|
|
hint="Отображаются в подвале личного кабинета ученика."
|
|
>
|
|
<textarea
|
|
value={s.orgRequisites}
|
|
onChange={(e) => set("orgRequisites", e.target.value)}
|
|
rows={3}
|
|
placeholder={"ИП Иванов Иван Иванович\nИНН 123456789012\nОГРНИП 123456789012345"}
|
|
style={{ ...inputStyle, resize: "vertical" }}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
</Section>
|
|
|
|
{/* ── 5. Права куратора ── */}
|
|
<Section title="Права куратора">
|
|
<SelectField
|
|
label="Куратор видит домашние задания"
|
|
value={s.curatorHomeworkScope}
|
|
onChange={(v) => set("curatorHomeworkScope", v)}
|
|
options={[
|
|
{ value: "all", label: "По всем курсам" },
|
|
{ value: "assigned", label: "Только по назначенным курсам" },
|
|
]}
|
|
/>
|
|
<div className="space-y-3 pt-1">
|
|
<Toggle
|
|
label="Куратор может отвечать на вопросы учеников"
|
|
checked={bool("curatorCanAnswerQuestions")}
|
|
onChange={(v) => set("curatorCanAnswerQuestions", v ? "true" : "false")}
|
|
/>
|
|
<Toggle
|
|
label="Куратор видит список всех студентов"
|
|
checked={bool("curatorCanSeeStudents")}
|
|
onChange={(v) => set("curatorCanSeeStudents", v ? "true" : "false")}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* ── 6. Вставка кода ── */}
|
|
<Section
|
|
title="Вставка кода"
|
|
hint="Произвольный HTML/JS — Яндекс.Метрика, Google Analytics, виджеты. Код добавляется на каждую страницу."
|
|
>
|
|
<Field label="Код в <head>" hint="Счётчики, пиксели, мета-теги">
|
|
<textarea
|
|
value={s.headCode}
|
|
onChange={(e) => set("headCode", e.target.value)}
|
|
rows={4}
|
|
placeholder={"<!-- Яндекс.Метрика -->\n<script>...</script>"}
|
|
style={{
|
|
...inputStyle,
|
|
resize: "vertical",
|
|
fontFamily: "var(--font-mono)",
|
|
fontSize: "0.8rem",
|
|
}}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
<Field label="Код в <body>" hint="Чаты поддержки, виджеты">
|
|
<textarea
|
|
value={s.bodyCode}
|
|
onChange={(e) => set("bodyCode", e.target.value)}
|
|
rows={4}
|
|
placeholder={"<!-- JivoSite / Crisp / etc -->\n<script>...</script>"}
|
|
style={{
|
|
...inputStyle,
|
|
resize: "vertical",
|
|
fontFamily: "var(--font-mono)",
|
|
fontSize: "0.8rem",
|
|
}}
|
|
{...focusHandlers}
|
|
/>
|
|
</Field>
|
|
</Section>
|
|
|
|
{/* Bottom save button */}
|
|
<div className="flex justify-end pb-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={pending}
|
|
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
|
|
style={{ opacity: pending ? 0.6 : 1 }}
|
|
>
|
|
<Save size={14} />
|
|
{pending ? "Сохранение..." : saved ? "Сохранено ✓" : "Сохранить настройки"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|