import { useEffect, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Building2Icon, CheckIcon, ClockIcon, TriangleAlertIcon, ExternalLinkIcon, FileTextIcon, LocateFixedIcon, MapPinnedIcon, } from 'lucide-react'; import { useForm, useWatch, type FieldErrors } from 'react-hook-form'; import { Link, useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { z } from 'zod'; import { AddressSearch } from '../../components/seo/address-search'; import { OptionRow, OptionSection, SelectRow, TextRow, ToggleRow, } from '../../components/seo/option-row'; import { SeoPage, SeoPageError, SeoPageHeader, SeoPageLoading, } from '../../components/seo/seo-page'; import { SettingsPanel, SettingsSection, SettingsShell, type SectionItem, } from '../../components/seo/settings-shell'; import { ModuleOffNotice } from '../../components/seo/module-off-notice'; import { Button } from '../../components/ui/button'; import { CONTACT_KEYS, localSection } from '../../lib/seo/option-sections'; import { PHONE_ERROR, isPhoneOrEmpty } from '../../lib/seo/phone'; import { resetSectionFields, sectionFields } from '../../lib/seo/reset-section'; import { firstErrorPath, useSeoSettings } from '../../lib/seo/use-seo-settings'; import type { ISeoAddress, ISeoHoursRead, ISeoSettings, } from '../../service/seo/seo.interface'; import { readSeoHours, searchSeoAddress, updateSeoSettings, } from '../../service/seo/seo.service'; import { LOCAL, LOCAL_SECTIONS, PAGES } from './settings.copy'; /** * How each line of the opening hours reads, as crawlers will get it, and * the lines that cannot be read and so never reach them. */ const HoursReading = ({ text }: { text: string }): JSX.Element | null => { const [lines, setLines] = useState([]); useEffect(() => { if (text.trim() === '') { setLines([]); return undefined; } const timer = window.setTimeout(() => { readSeoHours(text) .then(result => setLines(result.lines)) .catch(() => setLines([])); }, 400); return () => window.clearTimeout(timer); }, [text]); if (lines.length === 0) { return null; } return ( ); }; const coordinate = (limit: number) => z .string() .trim() .refine( value => value === '' || (Number.isFinite(Number(value)) && Math.abs(Number(value)) <= limit), `A decimal number between -${limit} and ${limit}, or empty.` ); const schema = z.object({ local: z.object({ business_type: z.string(), name: z.string().trim().max(120, 'Too long.'), street: z.string().trim().max(200, 'Too long.'), locality: z.string().trim().max(120, 'Too long.'), region: z.string().trim().max(120, 'Too long.'), postal_code: z.string().trim().max(20, 'Too long.'), country: z.string().trim().max(60, 'Too long.'), phone: z.string().trim().refine(isPhoneOrEmpty, PHONE_ERROR), email: z .string() .trim() .refine( value => value === '' || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), 'Not an email address.' ), price_range: z.string().trim().max(30, '30 characters at most.'), latitude: coordinate(90), longitude: coordinate(180), opening_hours: z.string(), about_page: z.coerce.number().int().min(0), contact_page: z.coerce.number().int().min(0), show_map: z.boolean(), map_provider: z.enum(['osm', 'google']), google_maps_key: z.string().trim().max(80, 'Too long.'), multiple_locations: z.boolean(), }), }); type FormValues = z.input; type FormOutput = z.output; const SECTION_ITEMS: SectionItem[] = [ { key: 'business', label: LOCAL_SECTIONS.business.title, description: LOCAL_SECTIONS.business.description, icon: Building2Icon, }, { key: 'contact', label: LOCAL_SECTIONS.contact.title, description: LOCAL_SECTIONS.contact.description, icon: ClockIcon, }, { key: 'pages', label: LOCAL_SECTIONS.pages.title, description: LOCAL_SECTIONS.pages.description, icon: FileTextIcon, }, { key: 'locations', label: LOCAL_SECTIONS.locations.title, description: LOCAL_SECTIONS.locations.description, icon: MapPinnedIcon, }, ]; const BUSINESS_KEYS = [ 'name', 'street', 'locality', 'region', 'postal_code', 'country', ] as const; const toFormValues = (settings: ISeoSettings): FormValues => ({ local: { ...settings.local }, }); /** * The coordinates of the address typed in the fields above, for a merchant * who filled them by hand. The lookup goes through the site, like the search * box: the map services want to know which application is calling. */ const GeocodeButton = ({ address, onFound, }: { address: string[]; onFound: (latitude: string, longitude: string) => void; }): JSX.Element => { const [busy, setBusy] = useState(false); const [note, setNote] = useState(''); const query = address .map(part => part.trim()) .filter(Boolean) .join(', '); const lookUp = async (): Promise => { setBusy(true); setNote(''); try { const { items } = await searchSeoAddress(query); const found = items.find(item => item.latitude !== ''); if (!found) { setNote('No match; check the address or type the coordinates by hand.'); return; } onFound(found.latitude, found.longitude); setNote(`Found: ${found.label}`); } catch { setNote('The lookup failed; try again in a moment.'); } finally { setBusy(false); } }; return (
{note &&

{note}

}
); }; /** * A small map around the coordinates typed in, from the service the * settings name: Google Maps with the key, OpenStreetMap otherwise. */ const MapPreview = ({ latitude, longitude, provider, googleKey, }: { latitude: string; longitude: string; provider: string; googleKey: string; }): JSX.Element | null => { const lat = Number(latitude); const lng = Number(longitude); // Past the poles or the date line (a latitude still being typed) the map // threw "Invalid LngLat" at the page. if ( latitude.trim() === '' || longitude.trim() === '' || !Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180 ) { return null; } const point = encodeURIComponent(`${lat},${lng}`); const bbox = [lng - 0.01, lat - 0.01, lng + 0.01, lat + 0.01].join(','); const key = googleKey.trim(); const src = provider === 'google' && key !== '' ? `https://www.google.com/maps/embed/v1/place?key=${encodeURIComponent(key)}&q=${point}&zoom=15` : `https://www.openstreetmap.org/export/embed.html?bbox=${encodeURIComponent(bbox)}&layer=mapnik&marker=${point}`; return (