diff --git a/src/app/admin/_components/SectionEditor.tsx b/src/app/admin/_components/SectionEditor.tsx index ecd7720..d590c08 100644 --- a/src/app/admin/_components/SectionEditor.tsx +++ b/src/app/admin/_components/SectionEditor.tsx @@ -8,6 +8,8 @@ interface SectionEditorProps { sectionKey: string; title: string; defaultData?: Partial; + /** Return true if data is valid and can be saved. Blocks auto-save when false. */ + validate?: (data: T) => boolean; children: (data: T, update: (data: T) => void) => React.ReactNode; } @@ -17,6 +19,7 @@ export function SectionEditor({ sectionKey, title, defaultData, + validate, children, }: SectionEditorProps) { const [data, setData] = useState(null); @@ -67,6 +70,7 @@ export function SectionEditor({ if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { + if (validate && !validate(data)) return; save(data); }, DEBOUNCE_MS); diff --git a/src/app/admin/contact/page.tsx b/src/app/admin/contact/page.tsx index 24fa7fc..d843399 100644 --- a/src/app/admin/contact/page.tsx +++ b/src/app/admin/contact/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; -import { Plus, X, AlertCircle, Check } from "lucide-react"; +import { useState, useRef, useCallback } from "react"; +import { Plus, X, AlertCircle, Check, Loader2 } from "lucide-react"; import { SectionEditor } from "../_components/SectionEditor"; import { InputField } from "../_components/FormField"; import { CollapsibleSection } from "../_components/CollapsibleSection"; +import { adminFetch } from "@/lib/csrf"; import type { ContactInfo } from "@/types/content"; // --- Phone input with mask --- @@ -37,7 +38,7 @@ function PhoneField({ value, onChange }: { value: string; onChange: (v: string)
{value && !isComplete && ( -

Формат: +375 (XX) XXX-XX-XX

+

Формат: +375 (XX) XXX-XX-XX — данные не сохранятся

)}
); } -// --- Instagram field with validation --- +// --- Instagram field like team page (username with @ prefix + validation) --- function InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) { - function getError(): string | null { - if (!value) return null; - try { - const url = new URL(value); - const host = url.hostname.replace("www.", ""); - if (host !== "instagram.com" && host !== "instagr.am") { - return "Ссылка должна вести на instagram.com"; - } - return null; - } catch { - return "Некорректная ссылка"; - } + const [status, setStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle"); + const timerRef = useRef | null>(null); + + // Extract username from URL or @-prefixed input + function extractUsername(raw: string): string { + if (!raw) return ""; + return raw + .replace(/^https?:\/\/(www\.)?instagram\.com\//, "") + .replace(/\/$/, "") + .replace(/^@/, ""); } - const error = getError(); + const validateUsername = useCallback((username: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + if (!username) { setStatus("idle"); return; } + setStatus("checking"); + timerRef.current = setTimeout(async () => { + try { + const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`); + const result = await res.json(); + setStatus(result.valid ? "valid" : "invalid"); + } catch { + setStatus("idle"); + } + }, 800); + }, []); + + // On mount, if value exists, mark as valid (trusted existing data) + const initializedRef = useRef(false); + if (value && !initializedRef.current) { + initializedRef.current = true; + if (status === "idle") setStatus("valid"); + } return (
+ @ onChange(e.target.value)} - placeholder="https://instagram.com/blackheartdancehouse" - className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${ - error ? "border-red-500/50" : "border-white/10 focus:border-gold" + type="text" + value={value ?? ""} + onChange={(e) => { + const username = extractUsername(e.target.value); + onChange(username); + validateUsername(username); + }} + placeholder="blackheartdancehouse" + className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 transition-colors ${ + status === "invalid" + ? "border-red-500 focus:border-red-500" + : status === "valid" + ? "border-green-500/50 focus:border-green-500" + : "border-white/10 focus:border-gold" }`} /> - {value && !error && ( - - )} - {error && ( - - )} + + {status === "checking" && } + {status === "valid" && } + {status === "invalid" && } +
- {error && ( -

{error}

+ {status === "invalid" && ( +

Аккаунт не найден

+ )} + {value && status !== "invalid" && status !== "checking" && ( + + instagram.com/{value} ↗ + )}
); @@ -161,9 +198,19 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s ); } +function isPhoneValid(phone: string | undefined): boolean { + if (!phone) return true; // empty is ok + return (phone.replace(/\D/g, "")).length === 12; +} + export default function ContactEditorPage() { return ( - sectionKey="contact" title="Контакты" defaultData={{ addresses: [] }}> + + sectionKey="contact" + title="Контакты" + defaultData={{ addresses: [], instagram: "" }} + validate={(data) => isPhoneValid(data.phone)} + > {(data, update) => (
update({ ...data, phone: v })} /> update({ ...data, instagram: v })} + value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")} + onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })} />
@@ -191,11 +238,10 @@ export default function ContactEditorPage() { update({ ...data, addresses })} /> - )} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 93f3fe3..5ed4cee 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -30,6 +30,7 @@ const NAV_ITEMS = [ { href: "/admin", label: "Дашборд", icon: LayoutDashboard }, { href: "/admin/meta", label: "SEO / Мета", icon: Globe }, { href: "/admin/bookings", label: "Записи", icon: ClipboardList }, + { href: "/admin/popups", label: "Формы записи", icon: MessageSquare }, // Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact { href: "/admin/hero", label: "Главный экран", icon: Sparkles }, { href: "/admin/about", label: "О студии", icon: FileText }, @@ -41,7 +42,6 @@ const NAV_ITEMS = [ { href: "/admin/master-classes", label: "Мастер-классы", icon: Star }, { href: "/admin/news", label: "Новости", icon: Newspaper }, { href: "/admin/faq", label: "FAQ", icon: HelpCircle }, - { href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare }, { href: "/admin/contact", label: "Контакты", icon: Phone }, ]; diff --git a/src/app/admin/popups/page.tsx b/src/app/admin/popups/page.tsx index a9508d6..e15f5a9 100644 --- a/src/app/admin/popups/page.tsx +++ b/src/app/admin/popups/page.tsx @@ -14,7 +14,7 @@ export default function PopupsEditorPage() { return ( sectionKey="popups" - title="Тексты всплывающих окон" + title="Формы записи" > {(data, update) => (
diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index b827c2a..1bb69fa 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -952,13 +952,18 @@ function CalendarGrid({ value={location.name} onChange={(v) => onChange({ ...location, name: v })} /> - onChange({ ...location, address: v })} - options={addresses.map((a) => ({ value: a, label: a }))} - placeholder="Выберите адрес" - /> +
+ onChange({ ...location, address: v })} + options={addresses.map((a) => ({ value: a, label: a }))} + placeholder="Выберите адрес" + /> +

+ Адреса задаются в Контактах +

+
{/* Calendar */} diff --git a/src/components/sections/Contact.tsx b/src/components/sections/Contact.tsx index cbdc498..fd3aa38 100644 --- a/src/components/sections/Contact.tsx +++ b/src/components/sections/Contact.tsx @@ -3,6 +3,7 @@ import { BRAND } from "@/lib/constants"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; import { IconBadge } from "@/components/ui/IconBadge"; +import { YandexMap } from "@/components/ui/YandexMap"; import type { ContactInfo } from "@/types/content"; interface ContactProps { @@ -57,20 +58,13 @@ export function Contact({ data: contact }: ContactProps) { - -
-