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">
+5
View File
@@ -952,6 +952,7 @@ function CalendarGrid({
value={location.name}
onChange={(v) => onChange({ ...location, name: v })}
/>
<div>
<SelectField
label="Адрес"
value={location.address}
@@ -959,6 +960,10 @@ function CalendarGrid({
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 */}
+4 -10
View File
@@ -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) {
</div>
</Reveal>
{contact.addresses?.length > 0 && (
<Reveal>
<div className="overflow-hidden rounded-2xl border border-neutral-200 shadow-sm dark:border-white/[0.08] dark:shadow-[0_0_30px_rgba(201,169,110,0.05)]">
<iframe
src={contact.mapEmbedUrl}
width="100%"
height="380"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
title="Карта"
className="dark:invert dark:hue-rotate-180 dark:brightness-95 dark:contrast-90"
/>
<YandexMap addresses={contact.addresses} />
</div>
</Reveal>
)}
</div>
</section>
);
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
interface YandexMapProps {
addresses: string[];
height?: number;
}
function cleanAddress(addr: string): string {
return addr
.replace(/^г\.\s*/i, "")
.replace(/ул\.\s*/gi, "")
.replace(/пр-т\.?\s*/gi, "")
.replace(/пр\.\s*/gi, "")
.replace(/просп\.\s*/gi, "")
.replace(/,?\s*к\d+/gi, "")
.replace(/,+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
const [mapSrc, setMapSrc] = useState<string | null>(null);
useEffect(() => {
if (!addresses.length) return;
let cancelled = false;
async function build() {
const points: { lat: number; lon: number }[] = [];
for (const addr of addresses) {
try {
const cleaned = cleanAddress(addr);
const query = cleaned.toLowerCase().includes("минск") ? cleaned : `Минск ${cleaned}`;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=by`
);
const data = await res.json();
if (data.length > 0) {
points.push({ lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) });
}
} catch { /* skip */ }
}
if (cancelled || points.length === 0) return;
const centerLat = points.reduce((s, p) => s + p.lat, 0) / points.length;
const centerLon = points.reduce((s, p) => s + p.lon, 0) / points.length;
const zoom = points.length === 1 ? 15 : 12;
const pts = points.map((p) => `${p.lon},${p.lat},pm2ntl`).join("~");
setMapSrc(`https://yandex.ru/map-widget/v1/?ll=${centerLon},${centerLat}&z=${zoom}&pt=${pts}&l=map&theme=dark`);
}
build();
return () => { cancelled = true; };
}, [addresses]);
if (!addresses.length) return null;
if (!mapSrc) {
return (
<div style={{ width: "100%", height }} className="flex items-center justify-center text-neutral-500 text-sm">
Загрузка карты...
</div>
);
}
return (
<iframe
src={mapSrc}
width="100%"
height={height}
style={{ border: 0 }}
allowFullScreen
loading="lazy"
title="Карта"
/>
);
}