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;
|
sectionKey: string;
|
||||||
title: string;
|
title: string;
|
||||||
defaultData?: Partial<T>;
|
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;
|
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ export function SectionEditor<T>({
|
|||||||
sectionKey,
|
sectionKey,
|
||||||
title,
|
title,
|
||||||
defaultData,
|
defaultData,
|
||||||
|
validate,
|
||||||
children,
|
children,
|
||||||
}: SectionEditorProps<T>) {
|
}: SectionEditorProps<T>) {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
@@ -67,6 +70,7 @@ export function SectionEditor<T>({
|
|||||||
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
|
if (validate && !validate(data)) return;
|
||||||
save(data);
|
save(data);
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { Plus, X, AlertCircle, Check } from "lucide-react";
|
import { Plus, X, AlertCircle, Check, Loader2 } from "lucide-react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField } from "../_components/FormField";
|
import { InputField } from "../_components/FormField";
|
||||||
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { ContactInfo } from "@/types/content";
|
import type { ContactInfo } from "@/types/content";
|
||||||
|
|
||||||
// --- Phone input with mask ---
|
// --- Phone input with mask ---
|
||||||
@@ -37,7 +38,7 @@ function PhoneField({ value, onChange }: { value: string; onChange: (v: string)
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={value}
|
value={value ?? ""}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="+375 (XX) XXX-XX-XX"
|
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 ${
|
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>
|
</div>
|
||||||
{value && !isComplete && (
|
{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>
|
</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 InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
function getError(): string | null {
|
const [status, setStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||||
if (!value) return null;
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
// Extract username from URL or @-prefixed input
|
||||||
const host = url.hostname.replace("www.", "");
|
function extractUsername(raw: string): string {
|
||||||
if (host !== "instagram.com" && host !== "instagr.am") {
|
if (!raw) return "";
|
||||||
return "Ссылка должна вести на instagram.com";
|
return raw
|
||||||
}
|
.replace(/^https?:\/\/(www\.)?instagram\.com\//, "")
|
||||||
return null;
|
.replace(/\/$/, "")
|
||||||
} catch {
|
.replace(/^@/, "");
|
||||||
return "Некорректная ссылка";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
value={value}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder="https://instagram.com/blackheartdancehouse"
|
const username = extractUsername(e.target.value);
|
||||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
onChange(username);
|
||||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
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 && (
|
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
|
{status === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
|
||||||
)}
|
{status === "valid" && <Check size={14} className="text-green-400" />}
|
||||||
{error && (
|
{status === "invalid" && <AlertCircle size={14} className="text-red-400" />}
|
||||||
<AlertCircle size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400" />
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{status === "invalid" && (
|
||||||
<p className="mt-1 text-[11px] text-red-400">{error}</p>
|
<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>
|
</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() {
|
export default function ContactEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты" defaultData={{ addresses: [] }}>
|
<SectionEditor<ContactInfo>
|
||||||
|
sectionKey="contact"
|
||||||
|
title="Контакты"
|
||||||
|
defaultData={{ addresses: [], instagram: "" }}
|
||||||
|
validate={(data) => isPhoneValid(data.phone)}
|
||||||
|
>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<InputField
|
<InputField
|
||||||
@@ -178,8 +225,8 @@ export default function ContactEditorPage() {
|
|||||||
onChange={(v) => update({ ...data, phone: v })}
|
onChange={(v) => update({ ...data, phone: v })}
|
||||||
/>
|
/>
|
||||||
<InstagramField
|
<InstagramField
|
||||||
value={data.instagram}
|
value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
|
||||||
onChange={(v) => update({ ...data, instagram: v })}
|
onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,11 +238,10 @@ export default function ContactEditorPage() {
|
|||||||
|
|
||||||
<CollapsibleSection title="Адреса">
|
<CollapsibleSection title="Адреса">
|
||||||
<AddressList
|
<AddressList
|
||||||
items={data.addresses}
|
items={data.addresses ?? []}
|
||||||
onChange={(addresses) => update({ ...data, addresses })}
|
onChange={(addresses) => update({ ...data, addresses })}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
||||||
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
||||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
{ 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
|
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
|
||||||
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
||||||
{ href: "/admin/about", label: "О студии", icon: FileText },
|
{ href: "/admin/about", label: "О студии", icon: FileText },
|
||||||
@@ -41,7 +42,6 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
{ href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare },
|
|
||||||
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function PopupsEditorPage() {
|
|||||||
return (
|
return (
|
||||||
<SectionEditor<PopupsData>
|
<SectionEditor<PopupsData>
|
||||||
sectionKey="popups"
|
sectionKey="popups"
|
||||||
title="Тексты всплывающих окон"
|
title="Формы записи"
|
||||||
>
|
>
|
||||||
{(data, update) => (
|
{(data, update) => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -952,13 +952,18 @@ function CalendarGrid({
|
|||||||
value={location.name}
|
value={location.name}
|
||||||
onChange={(v) => onChange({ ...location, name: v })}
|
onChange={(v) => onChange({ ...location, name: v })}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<div>
|
||||||
label="Адрес"
|
<SelectField
|
||||||
value={location.address}
|
label="Адрес"
|
||||||
onChange={(v) => onChange({ ...location, address: v })}
|
value={location.address}
|
||||||
options={addresses.map((a) => ({ value: a, label: a }))}
|
onChange={(v) => onChange({ ...location, address: v })}
|
||||||
placeholder="Выберите адрес"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BRAND } from "@/lib/constants";
|
|||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { IconBadge } from "@/components/ui/IconBadge";
|
import { IconBadge } from "@/components/ui/IconBadge";
|
||||||
|
import { YandexMap } from "@/components/ui/YandexMap";
|
||||||
import type { ContactInfo } from "@/types/content";
|
import type { ContactInfo } from "@/types/content";
|
||||||
|
|
||||||
interface ContactProps {
|
interface ContactProps {
|
||||||
@@ -57,20 +58,13 @@ export function Contact({ data: contact }: ContactProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal>
|
{contact.addresses?.length > 0 && (
|
||||||
<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)]">
|
<Reveal>
|
||||||
<iframe
|
<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)]">
|
||||||
src={contact.mapEmbedUrl}
|
<YandexMap addresses={contact.addresses} />
|
||||||
width="100%"
|
</div>
|
||||||
height="380"
|
</Reveal>
|
||||||
style={{ border: 0 }}
|
)}
|
||||||
allowFullScreen
|
|
||||||
loading="lazy"
|
|
||||||
title="Карта"
|
|
||||||
className="dark:invert dark:hue-rotate-180 dark:brightness-95 dark:contrast-90"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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