feat: centralize popup texts in new admin tab /admin/popups

- New admin page for shared popup texts (success, waiting list, error, Instagram hint)
- Removed popup fields from MC and Open Day admin editors
- All SignupModals now read from centralized popups config
- Stored as "popups" section in DB with fallback defaults
This commit is contained in:
2026-03-25 23:48:06 +03:00
parent 983bf296fc
commit 6c485872b0
11 changed files with 100 additions and 62 deletions

View File

@@ -23,6 +23,7 @@ import {
ChevronLeft,
ClipboardList,
DoorOpen,
MessageSquare,
} from "lucide-react";
const NAV_ITEMS = [
@@ -39,6 +40,7 @@ const NAV_ITEMS = [
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
{ href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare },
{ href: "/admin/contact", label: "Контакты", icon: Phone },
];

View File

@@ -34,8 +34,6 @@ function PriceField({ label, value, onChange, placeholder }: { label: string; va
interface MasterClassesData {
title: string;
successMessage?: string;
waitingListText?: string;
items: MasterClassItem[];
}
@@ -386,21 +384,6 @@ export default function MasterClassesEditorPage() {
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Текст после записи (success popup)"
value={data.successMessage || ""}
onChange={(v) => update({ ...data, successMessage: v || undefined })}
placeholder="Вы записаны! Мы свяжемся с вами"
/>
<TextareaField
label="Текст для листа ожидания"
value={data.waitingListText || ""}
onChange={(v) => update({ ...data, waitingListText: v || undefined })}
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
rows={2}
/>
<ArrayEditor
label="Мастер-классы"
items={data.items}

View File

@@ -19,8 +19,6 @@ interface OpenDayEvent {
discountThreshold: number;
minBookings: number;
maxParticipants: number;
successMessage?: string;
waitingListText?: string;
active: boolean;
}
@@ -102,29 +100,6 @@ function EventSettings({
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Текст после записи</label>
<textarea
value={event.successMessage || ""}
onChange={(e) => onChange({ successMessage: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Вы записаны!"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Текст для листа ожидания</label>
<textarea
value={event.waitingListText || ""}
onChange={(e) => onChange({ waitingListText: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
/>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input

View File

@@ -0,0 +1,56 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { TextareaField } from "../_components/FormField";
interface PopupsData {
successMessage: string;
waitingListText: string;
errorMessage: string;
instagramHint: string;
}
export default function PopupsEditorPage() {
return (
<SectionEditor<PopupsData>
sectionKey="popups"
title="Тексты всплывающих окон"
>
{(data, update) => (
<div className="space-y-6">
<p className="text-sm text-neutral-500">
Эти тексты используются во всех формах записи: мастер-классы, день открытых дверей, групповые занятия.
</p>
<TextareaField
label="Успешная запись"
value={data.successMessage}
onChange={(v) => update({ ...data, successMessage: v })}
rows={2}
/>
<TextareaField
label="Лист ожидания"
value={data.waitingListText}
onChange={(v) => update({ ...data, waitingListText: v })}
rows={3}
/>
<TextareaField
label="Ошибка при записи"
value={data.errorMessage}
onChange={(v) => update({ ...data, errorMessage: v })}
rows={2}
/>
<TextareaField
label="Ссылка на Instagram (текст под сообщениями)"
value={data.instagramHint}
onChange={(v) => update({ ...data, instagramHint: v })}
rows={1}
/>
</div>
)}
</SectionEditor>
);
}

View File

@@ -30,7 +30,7 @@ export default function HomePage() {
<Header />
<main>
<Hero data={content.hero} />
{openDayData && <OpenDay data={openDayData} />}
{openDayData && <OpenDay data={openDayData} popups={content.popups} />}
<About
data={content.about}
stats={{
@@ -41,7 +41,7 @@ export default function HomePage() {
/>
<Team data={content.team} schedule={content.schedule.locations} />
<Classes data={content.classes} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
<Schedule data={content.schedule} classItems={content.classes.items} />
<Pricing data={content.pricing} />
<News data={content.news} />

View File

@@ -11,6 +11,7 @@ import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps {
data: SiteContent["masterClasses"];
regCounts?: Record<string, number>;
popups?: SiteContent["popups"];
}
const MONTHS_RU = [
@@ -217,7 +218,7 @@ function MasterClassCard({
);
}
export function MasterClasses({ data, regCounts = {} }: MasterClassesProps) {
export function MasterClasses({ data, regCounts = {}, popups }: MasterClassesProps) {
const [signupTitle, setSignupTitle] = useState<string | null>(null);
const upcoming = useMemo(() => {
@@ -278,8 +279,10 @@ export function MasterClasses({ data, regCounts = {} }: MasterClassesProps) {
subtitle={signupTitle ?? ""}
endpoint="/api/master-class-register"
extraBody={{ masterClassTitle: signupTitle }}
successMessage={data.successMessage}
waitingMessage={data.waitingListText}
successMessage={popups?.successMessage}
waitingMessage={popups?.waitingListText}
errorMessage={popups?.errorMessage}
instagramHint={popups?.instagramHint}
/>
</section>
);

View File

@@ -6,12 +6,14 @@ import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
import type { SiteContent } from "@/types";
interface OpenDayProps {
data: {
event: OpenDayEvent;
classes: OpenDayClass[];
};
popups?: SiteContent["popups"];
}
function formatDateRu(dateStr: string): string {
@@ -23,7 +25,7 @@ function formatDateRu(dateStr: string): string {
});
}
export function OpenDay({ data }: OpenDayProps) {
export function OpenDay({ data, popups }: OpenDayProps) {
const { event, classes } = data;
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
@@ -132,8 +134,10 @@ export function OpenDay({ data }: OpenDayProps) {
subtitle={signup.label}
endpoint="/api/open-day-register"
extraBody={{ classId: signup.classId, eventId: event.id }}
successMessage={event.successMessage}
waitingMessage={event.waitingListText}
successMessage={popups?.successMessage}
waitingMessage={popups?.waitingListText}
errorMessage={popups?.errorMessage}
instagramHint={popups?.instagramHint}
/>
)}
</section>

View File

@@ -18,6 +18,10 @@ interface SignupModalProps {
successMessage?: string;
/** Custom waiting list message */
waitingMessage?: string;
/** Custom error message */
errorMessage?: string;
/** Custom Instagram hint text */
instagramHint?: string;
/** Callback with API response data on success */
onSuccess?: (data: Record<string, unknown>) => void;
}
@@ -31,6 +35,8 @@ export function SignupModal({
extraBody,
successMessage,
waitingMessage,
errorMessage,
instagramHint,
onSuccess,
}: SignupModalProps) {
const [name, setName] = useState("");
@@ -167,7 +173,7 @@ export function SignupModal({
className="mt-3 inline-flex items-center gap-1.5 text-sm text-pink-400 hover:text-pink-300"
>
<Instagram size={14} />
По вопросам пишите в Instagram
{instagramHint || "По вопросам пишите в Instagram"}
</a>
</>
) : (
@@ -190,7 +196,7 @@ export function SignupModal({
</div>
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3>
<p className="mt-2 text-sm text-neutral-400">
Не удалось отправить заявку. Свяжитесь с нами через Instagram мы запишем вас!
{errorMessage || "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!"}
</p>
<button
onClick={openInstagramDM}

View File

@@ -312,6 +312,12 @@ export const siteContent: SiteContent = {
title: "Мастер-классы",
items: [],
},
popups: {
successMessage: "Вы записаны!",
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
instagramHint: "По вопросам пишите в Instagram",
},
schedule: {
title: "Расписание",
locations: [

View File

@@ -519,6 +519,7 @@ const SECTION_KEYS = [
"schedule",
"news",
"contact",
"popups",
] as const;
export function getSiteContent(): SiteContent | null {
@@ -549,6 +550,12 @@ export function getSiteContent(): SiteContent | null {
pricing: sections.pricing,
schedule: sections.schedule,
news: sections.news ?? { title: "Новости", items: [] },
popups: sections.popups ?? {
successMessage: "Вы записаны!",
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
instagramHint: "По вопросам пишите в Instagram",
},
contact: sections.contact,
team: {
title: teamSection.title || "",
@@ -1014,8 +1021,6 @@ export interface OpenDayEvent {
discountThreshold: number;
minBookings: number;
maxParticipants: number;
successMessage?: string;
waitingListText?: string;
active: boolean;
}
@@ -1098,8 +1103,6 @@ function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
discountThreshold: r.discount_threshold,
minBookings: r.min_bookings,
maxParticipants: r.max_participants ?? 0,
successMessage: r.success_message ?? undefined,
waitingListText: r.waiting_list_text ?? undefined,
active: !!r.active,
};
}
@@ -1219,8 +1222,6 @@ export function updateOpenDayEvent(
discountThreshold: number;
minBookings: number;
maxParticipants: number;
successMessage: string;
waitingListText: string;
active: boolean;
}>
): void {
@@ -1235,8 +1236,6 @@ export function updateOpenDayEvent(
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
if (data.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
if (data.successMessage !== undefined) { sets.push("success_message = ?"); vals.push(data.successMessage || null); }
if (data.waitingListText !== undefined) { sets.push("waiting_list_text = ?"); vals.push(data.waitingListText || null); }
if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
if (sets.length === 0) return;
sets.push("updated_at = datetime('now')");

View File

@@ -135,10 +135,14 @@ export interface SiteContent {
};
masterClasses: {
title: string;
successMessage?: string;
waitingListText?: string;
items: MasterClassItem[];
};
popups: {
successMessage: string;
waitingListText: string;
errorMessage: string;
instagramHint: string;
};
schedule: {
title: string;
locations: ScheduleLocation[];