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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function PopupsEditorPage() {
|
||||
return (
|
||||
<SectionEditor<PopupsData>
|
||||
sectionKey="popups"
|
||||
title="Тексты всплывающих окон"
|
||||
title="Формы записи"
|
||||
>
|
||||
{(data, update) => (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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="Карта"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user