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,
|
ChevronLeft,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
DoorOpen,
|
DoorOpen,
|
||||||
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -39,6 +40,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
|
{ href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare },
|
||||||
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ function PriceField({ label, value, onChange, placeholder }: { label: string; va
|
|||||||
|
|
||||||
interface MasterClassesData {
|
interface MasterClassesData {
|
||||||
title: string;
|
title: string;
|
||||||
successMessage?: string;
|
|
||||||
waitingListText?: string;
|
|
||||||
items: MasterClassItem[];
|
items: MasterClassItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,21 +384,6 @@ export default function MasterClassesEditorPage() {
|
|||||||
onChange={(v) => update({ ...data, title: v })}
|
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
|
<ArrayEditor
|
||||||
label="Мастер-классы"
|
label="Мастер-классы"
|
||||||
items={data.items}
|
items={data.items}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ interface OpenDayEvent {
|
|||||||
discountThreshold: number;
|
discountThreshold: number;
|
||||||
minBookings: number;
|
minBookings: number;
|
||||||
maxParticipants: number;
|
maxParticipants: number;
|
||||||
successMessage?: string;
|
|
||||||
waitingListText?: string;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,29 +100,6 @@ function EventSettings({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
||||||
<input
|
<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 />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Hero data={content.hero} />
|
<Hero data={content.hero} />
|
||||||
{openDayData && <OpenDay data={openDayData} />}
|
{openDayData && <OpenDay data={openDayData} popups={content.popups} />}
|
||||||
<About
|
<About
|
||||||
data={content.about}
|
data={content.about}
|
||||||
stats={{
|
stats={{
|
||||||
@@ -41,7 +41,7 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
<Team data={content.team} schedule={content.schedule.locations} />
|
<Team data={content.team} schedule={content.schedule.locations} />
|
||||||
<Classes data={content.classes} />
|
<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} />
|
<Schedule data={content.schedule} classItems={content.classes.items} />
|
||||||
<Pricing data={content.pricing} />
|
<Pricing data={content.pricing} />
|
||||||
<News data={content.news} />
|
<News data={content.news} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
|||||||
interface MasterClassesProps {
|
interface MasterClassesProps {
|
||||||
data: SiteContent["masterClasses"];
|
data: SiteContent["masterClasses"];
|
||||||
regCounts?: Record<string, number>;
|
regCounts?: Record<string, number>;
|
||||||
|
popups?: SiteContent["popups"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTHS_RU = [
|
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 [signupTitle, setSignupTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
const upcoming = useMemo(() => {
|
const upcoming = useMemo(() => {
|
||||||
@@ -278,8 +279,10 @@ export function MasterClasses({ data, regCounts = {} }: MasterClassesProps) {
|
|||||||
subtitle={signupTitle ?? ""}
|
subtitle={signupTitle ?? ""}
|
||||||
endpoint="/api/master-class-register"
|
endpoint="/api/master-class-register"
|
||||||
extraBody={{ masterClassTitle: signupTitle }}
|
extraBody={{ masterClassTitle: signupTitle }}
|
||||||
successMessage={data.successMessage}
|
successMessage={popups?.successMessage}
|
||||||
waitingMessage={data.waitingListText}
|
waitingMessage={popups?.waitingListText}
|
||||||
|
errorMessage={popups?.errorMessage}
|
||||||
|
instagramHint={popups?.instagramHint}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { SectionHeading } from "@/components/ui/SectionHeading";
|
|||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
|
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
|
||||||
|
import type { SiteContent } from "@/types";
|
||||||
|
|
||||||
interface OpenDayProps {
|
interface OpenDayProps {
|
||||||
data: {
|
data: {
|
||||||
event: OpenDayEvent;
|
event: OpenDayEvent;
|
||||||
classes: OpenDayClass[];
|
classes: OpenDayClass[];
|
||||||
};
|
};
|
||||||
|
popups?: SiteContent["popups"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRu(dateStr: string): string {
|
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 { event, classes } = data;
|
||||||
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
||||||
|
|
||||||
@@ -132,8 +134,10 @@ export function OpenDay({ data }: OpenDayProps) {
|
|||||||
subtitle={signup.label}
|
subtitle={signup.label}
|
||||||
endpoint="/api/open-day-register"
|
endpoint="/api/open-day-register"
|
||||||
extraBody={{ classId: signup.classId, eventId: event.id }}
|
extraBody={{ classId: signup.classId, eventId: event.id }}
|
||||||
successMessage={event.successMessage}
|
successMessage={popups?.successMessage}
|
||||||
waitingMessage={event.waitingListText}
|
waitingMessage={popups?.waitingListText}
|
||||||
|
errorMessage={popups?.errorMessage}
|
||||||
|
instagramHint={popups?.instagramHint}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface SignupModalProps {
|
|||||||
successMessage?: string;
|
successMessage?: string;
|
||||||
/** Custom waiting list message */
|
/** Custom waiting list message */
|
||||||
waitingMessage?: string;
|
waitingMessage?: string;
|
||||||
|
/** Custom error message */
|
||||||
|
errorMessage?: string;
|
||||||
|
/** Custom Instagram hint text */
|
||||||
|
instagramHint?: string;
|
||||||
/** Callback with API response data on success */
|
/** Callback with API response data on success */
|
||||||
onSuccess?: (data: Record<string, unknown>) => void;
|
onSuccess?: (data: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
@@ -31,6 +35,8 @@ export function SignupModal({
|
|||||||
extraBody,
|
extraBody,
|
||||||
successMessage,
|
successMessage,
|
||||||
waitingMessage,
|
waitingMessage,
|
||||||
|
errorMessage,
|
||||||
|
instagramHint,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: SignupModalProps) {
|
}: SignupModalProps) {
|
||||||
const [name, setName] = useState("");
|
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"
|
className="mt-3 inline-flex items-center gap-1.5 text-sm text-pink-400 hover:text-pink-300"
|
||||||
>
|
>
|
||||||
<Instagram size={14} />
|
<Instagram size={14} />
|
||||||
По вопросам пишите в Instagram
|
{instagramHint || "По вопросам пишите в Instagram"}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -190,7 +196,7 @@ export function SignupModal({
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3>
|
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3>
|
||||||
<p className="mt-2 text-sm text-neutral-400">
|
<p className="mt-2 text-sm text-neutral-400">
|
||||||
Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!
|
{errorMessage || "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!"}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={openInstagramDM}
|
onClick={openInstagramDM}
|
||||||
|
|||||||
@@ -312,6 +312,12 @@ export const siteContent: SiteContent = {
|
|||||||
title: "Мастер-классы",
|
title: "Мастер-классы",
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
popups: {
|
||||||
|
successMessage: "Вы записаны!",
|
||||||
|
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
|
||||||
|
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
|
||||||
|
instagramHint: "По вопросам пишите в Instagram",
|
||||||
|
},
|
||||||
schedule: {
|
schedule: {
|
||||||
title: "Расписание",
|
title: "Расписание",
|
||||||
locations: [
|
locations: [
|
||||||
|
|||||||
@@ -519,6 +519,7 @@ const SECTION_KEYS = [
|
|||||||
"schedule",
|
"schedule",
|
||||||
"news",
|
"news",
|
||||||
"contact",
|
"contact",
|
||||||
|
"popups",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function getSiteContent(): SiteContent | null {
|
export function getSiteContent(): SiteContent | null {
|
||||||
@@ -549,6 +550,12 @@ export function getSiteContent(): SiteContent | null {
|
|||||||
pricing: sections.pricing,
|
pricing: sections.pricing,
|
||||||
schedule: sections.schedule,
|
schedule: sections.schedule,
|
||||||
news: sections.news ?? { title: "Новости", items: [] },
|
news: sections.news ?? { title: "Новости", items: [] },
|
||||||
|
popups: sections.popups ?? {
|
||||||
|
successMessage: "Вы записаны!",
|
||||||
|
waitingListText: "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам.",
|
||||||
|
errorMessage: "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!",
|
||||||
|
instagramHint: "По вопросам пишите в Instagram",
|
||||||
|
},
|
||||||
contact: sections.contact,
|
contact: sections.contact,
|
||||||
team: {
|
team: {
|
||||||
title: teamSection.title || "",
|
title: teamSection.title || "",
|
||||||
@@ -1014,8 +1021,6 @@ export interface OpenDayEvent {
|
|||||||
discountThreshold: number;
|
discountThreshold: number;
|
||||||
minBookings: number;
|
minBookings: number;
|
||||||
maxParticipants: number;
|
maxParticipants: number;
|
||||||
successMessage?: string;
|
|
||||||
waitingListText?: string;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1098,8 +1103,6 @@ function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
|
|||||||
discountThreshold: r.discount_threshold,
|
discountThreshold: r.discount_threshold,
|
||||||
minBookings: r.min_bookings,
|
minBookings: r.min_bookings,
|
||||||
maxParticipants: r.max_participants ?? 0,
|
maxParticipants: r.max_participants ?? 0,
|
||||||
successMessage: r.success_message ?? undefined,
|
|
||||||
waitingListText: r.waiting_list_text ?? undefined,
|
|
||||||
active: !!r.active,
|
active: !!r.active,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1219,8 +1222,6 @@ export function updateOpenDayEvent(
|
|||||||
discountThreshold: number;
|
discountThreshold: number;
|
||||||
minBookings: number;
|
minBookings: number;
|
||||||
maxParticipants: number;
|
maxParticipants: number;
|
||||||
successMessage: string;
|
|
||||||
waitingListText: string;
|
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}>
|
}>
|
||||||
): void {
|
): void {
|
||||||
@@ -1235,8 +1236,6 @@ export function updateOpenDayEvent(
|
|||||||
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
|
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.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
|
||||||
if (data.maxParticipants !== undefined) { sets.push("max_participants = ?"); vals.push(data.maxParticipants); }
|
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 (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
|
||||||
if (sets.length === 0) return;
|
if (sets.length === 0) return;
|
||||||
sets.push("updated_at = datetime('now')");
|
sets.push("updated_at = datetime('now')");
|
||||||
|
|||||||
@@ -135,10 +135,14 @@ export interface SiteContent {
|
|||||||
};
|
};
|
||||||
masterClasses: {
|
masterClasses: {
|
||||||
title: string;
|
title: string;
|
||||||
successMessage?: string;
|
|
||||||
waitingListText?: string;
|
|
||||||
items: MasterClassItem[];
|
items: MasterClassItem[];
|
||||||
};
|
};
|
||||||
|
popups: {
|
||||||
|
successMessage: string;
|
||||||
|
waitingListText: string;
|
||||||
|
errorMessage: string;
|
||||||
|
instagramHint: string;
|
||||||
|
};
|
||||||
schedule: {
|
schedule: {
|
||||||
title: string;
|
title: string;
|
||||||
locations: ScheduleLocation[];
|
locations: ScheduleLocation[];
|
||||||
|
|||||||
Reference in New Issue
Block a user