feat: contact page improvements, Yandex map from addresses

- Instagram field: @username input with API validation (like team page)
- Phone validation: blocks auto-save when incomplete, shows warning
- SectionEditor: validate prop to conditionally block saves
- Yandex Map: auto-generated from addresses via Nominatim geocoding,
  dark theme, no API key needed
- Schedule: address hint linking to Contacts
- Renamed "Всплывающие окна" → "Формы записи", moved after Записи
This commit is contained in:
2026-03-30 16:59:24 +03:00
parent 22bd117dae
commit 06be6b48ce
7 changed files with 191 additions and 60 deletions
@@ -8,6 +8,8 @@ interface SectionEditorProps<T> {
sectionKey: string;
title: string;
defaultData?: Partial<T>;
/** 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<T>({
sectionKey,
title,
defaultData,
validate,
children,
}: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null);
@@ -67,6 +70,7 @@ export function SectionEditor<T>({
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (validate && !validate(data)) return;
save(data);
}, DEBOUNCE_MS);
+83 -37
View File
@@ -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)
<div className="relative">
<input
type="tel"
value={value}
value={value ?? ""}
onChange={handleChange}
placeholder="+375 (XX) XXX-XX-XX"
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
@@ -49,52 +50,88 @@ function PhoneField({ value, onChange }: { value: string; onChange: (v: string)
)}
</div>
{value && !isComplete && (
<p className="mt-1 text-[11px] text-red-400">Формат: +375 (XX) XXX-XX-XX</p>
<p className="mt-1 text-xs text-red-400">Формат: +375 (XX) XXX-XX-XX данные не сохранятся</p>
)}
</div>
);
}
// --- 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<ReturnType<typeof setTimeout> | 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 (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
<input
type="url"
value={value}
onChange={(e) => 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 && (
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
)}
{error && (
<AlertCircle size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400" />
)}
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{status === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
{status === "valid" && <Check size={14} className="text-green-400" />}
{status === "invalid" && <AlertCircle size={14} className="text-red-400" />}
</span>
</div>
{error && (
<p className="mt-1 text-[11px] text-red-400">{error}</p>
{status === "invalid" && (
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
)}
{value && status !== "invalid" && status !== "checking" && (
<a
href={`https://instagram.com/${value}`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-xs text-neutral-500 hover:text-gold transition-colors"
>
instagram.com/{value}
</a>
)}
</div>
);
@@ -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 (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты" defaultData={{ addresses: [] }}>
<SectionEditor<ContactInfo>
sectionKey="contact"
title="Контакты"
defaultData={{ addresses: [], instagram: "" }}
validate={(data) => isPhoneValid(data.phone)}
>
{(data, update) => (
<div className="space-y-4">
<InputField
@@ -178,8 +225,8 @@ export default function ContactEditorPage() {
onChange={(v) => update({ ...data, phone: v })}
/>
<InstagramField
value={data.instagram}
onChange={(v) => update({ ...data, instagram: v })}
value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
/>
</div>
@@ -191,11 +238,10 @@ export default function ContactEditorPage() {
<CollapsibleSection title="Адреса">
<AddressList
items={data.addresses}
items={data.addresses ?? []}
onChange={(addresses) => update({ ...data, addresses })}
/>
</CollapsibleSection>
</div>
)}
</SectionEditor>
+1 -1
View File
@@ -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 },
];
+1 -1
View File
@@ -14,7 +14,7 @@ export default function PopupsEditorPage() {
return (
<SectionEditor<PopupsData>
sectionKey="popups"
title="Тексты всплывающих окон"
title="Формы записи"
>
{(data, update) => (
<div className="space-y-6">
+12 -7
View File
@@ -952,13 +952,18 @@ function CalendarGrid({
value={location.name}
onChange={(v) => onChange({ ...location, name: v })}
/>
<SelectField
label="Адрес"
value={location.address}
onChange={(v) => onChange({ ...location, address: v })}
options={addresses.map((a) => ({ value: a, label: a }))}
placeholder="Выберите адрес"
/>
<div>
<SelectField
label="Адрес"
value={location.address}
onChange={(v) => onChange({ ...location, address: v })}
options={addresses.map((a) => ({ value: a, label: a }))}
placeholder="Выберите адрес"
/>
<p className="mt-1 text-xs text-neutral-500">
Адреса задаются в <a href="/admin/contact" className="text-gold hover:text-gold-light transition-colors">Контактах</a>
</p>
</div>
</div>
{/* Calendar */}