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:
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
56
src/app/admin/popups/page.tsx
Normal file
56
src/app/admin/popups/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -312,6 +312,12 @@ export const siteContent: SiteContent = {
|
||||
title: "Мастер-классы",
|
||||
items: [],
|
||||
},
|
||||
popups: {
|
||||
successMessage: "Вы записаны!",
|
||||
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
|
||||
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
|
||||
instagramHint: "По вопросам пишите в Instagram",
|
||||
},
|
||||
schedule: {
|
||||
title: "Расписание",
|
||||
locations: [
|
||||
|
||||
@@ -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')");
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user