import { useEffect, useMemo, useState } from 'react'; import { CheckIcon, LoaderCircleIcon } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { MODULE_COPY } from '../../components/seo/modules-grid'; import { ImageField, OptionRow, OptionSection, } from '../../components/seo/option-row'; import { ROBOTS_PRESETS } from '../../components/seo/robots-crawlers'; import { SeoPage, SeoPageError, SeoPageHeader, SeoPageLoading, } from '../../components/seo/seo-page'; import { Button } from '../../components/ui/button'; import { ErrorCallout } from '../../components/ui/callout'; import { Card, CardContent } from '../../components/ui/card'; import { Input } from '../../components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../components/ui/select'; import { Switch } from '../../components/ui/switch'; import { Textarea } from '../../components/ui/textarea'; import { useSeoBadges } from '../../context/seo-badges.context'; import { useQuietLauncher } from '../../lib/copilot/quiet-launcher'; import { matchingPreset, presetPolicy, type RobotsPreset, } from '../../lib/seo/robots-policy'; import { withSuggestedModules } from '../../lib/seo/suggested-modules'; import { X_HANDLE_ERROR, xHandle } from '../../lib/seo/x-handle'; import { useSeoSettings } from '../../lib/seo/use-seo-settings'; import { classNames } from '../../lib/utils'; import type { ISeoFiles, ISeoImportSource, ISeoSettingsPatch, SeoImportPart, } from '../../service/seo/seo.interface'; import { fetchSeoFiles, fetchSeoImportSources, runSeoImportBatch, saveSeoRobots, switchSeoPlugin, updateSeoSettings, } from '../../service/seo/seo.service'; const STEPS = [ { title: 'Bring your data over', description: 'What another SEO plugin left in this database, copied into Recomaze before anything else.', }, { title: 'What this site is', description: 'The kind of site, and who search engines should credit: the organization or the person behind it, its logo and the picture its pages share with.', }, { title: 'Where it is on social', description: 'The profiles that belong to the site, for the knowledge graph and the social tags.', }, { title: 'What may be indexed', description: 'Which kinds of page and archive belong in search results, and whether the sitemap lists them.', }, { title: 'What else to switch on', description: 'The modules that write their own tables or watch the site, and how links to other sites behave; each can be changed later.', }, { title: 'AI engines', description: 'Which AI crawlers may read the site, and the one line llms.txt opens with, the way answer engines are told what the site is.', }, ] as const; /** The kinds of site the setup knows, and what each suggests for the steps after. */ const SITE_KINDS: { value: string; label: string; owner: 'organization' | 'person'; modules: string[]; }[] = [ { value: 'shop', label: 'An online shop', owner: 'organization', modules: ['redirects', 'monitor_404', 'image_seo'], }, { value: 'business', label: 'A business or a service', owner: 'organization', modules: ['redirects', 'monitor_404', 'local_seo'], }, { value: 'blog', label: 'A blog or a magazine', owner: 'organization', modules: ['redirects', 'image_seo'], }, { value: 'personal', label: 'A personal site or portfolio', owner: 'person', modules: ['redirects'], }, ]; const IMPORT_PARTS: SeoImportPart[] = [ 'settings', 'posts', 'terms', 'users', 'redirects', '404', ]; /** How each part reads in the line saying what an import brought over. */ const IMPORT_SUMMARY: { part: SeoImportPart; one: string; many: string }[] = [ { part: 'posts', one: 'page with SEO fields', many: 'pages with SEO fields' }, { part: 'terms', one: 'term', many: 'terms' }, { part: 'users', one: 'author', many: 'authors' }, { part: 'redirects', one: 'redirect', many: 'redirects' }, { part: '404', one: '404 entry', many: '404 entries' }, ]; /** The modules this step offers, in the order a new site wants them. */ const SETUP_MODULES = ['redirects', 'monitor_404', 'image_seo', 'local_seo']; const Stepper = ({ step, saved, onStep, }: { step: number; /** The steps written to the settings; only those get a tick. */ saved: ReadonlySet; onStep: (step: number) => void; }): JSX.Element => ( // One row: with room, every step is named; narrower (the Copilot open), // only the current one, the others by number with their name on hover.
    {STEPS.map((entry, index) => (
  1. ))}
); /** * Copies everything one other plugin left behind, part by part, and says * how far it got. */ const ImportRow = ({ source, onDone, }: { source: ISeoImportSource; onDone: () => Promise; }): JSX.Element => { const [running, setRunning] = useState(false); const [done, setDone] = useState<'imported' | 'switched' | null>(null); const [brought, setBrought] = useState< Partial> >({}); // A switch put back says why at length: it stays on the page. const [failure, setFailure] = useState(null); const counts = IMPORT_PARTS.map(part => part === 'settings' ? Number(source.counts.settings) : part === '404' ? source.counts.logs_404 : source.counts[part] ); const total = counts.reduce((sum, count) => sum + count, 0); const run = async (andSwitch: boolean): Promise => { setRunning(true); setFailure(null); try { for (const part of IMPORT_PARTS) { let offset = 0; for (;;) { const batch = await runSeoImportBatch(source.slug, part, offset); setBrought(current => ({ ...current, [part]: batch.total })); if (batch.done) { break; } offset = batch.next; } } if (andSwitch) { await switchSeoPlugin(source.slug); } // The next steps start from the settings as the import left them. await onDone(); setDone(andSwitch ? 'switched' : 'imported'); toast.success( andSwitch ? `Everything from ${source.name} is in Recomaze, and ${source.name} is deactivated` : `Everything from ${source.name} is in Recomaze` ); } catch (error) { setFailure((error as Error).message); } finally { setRunning(false); } }; const summary = IMPORT_SUMMARY.map(({ part, one, many }) => { const count = brought[part] ?? 0; return count > 0 ? `${count} ${count === 1 ? one : many}` : ''; }) .filter(Boolean) .join(', '); const state = done === 'switched' ? ` ${source.name} is switched off now; its data stays in the database.` : source.active ? ` ${source.name} is active and prints the head until it is switched off: “Import & switch” deactivates it once everything is in (its data stays).` : ''; return (
{source.active && !done && ( )}
{summary && (

Brought over: {summary}.

)} {failure && }
); }; /** * What Google asks of a logo, checked on the picture itself: at least * 112 pixels each way, and close to square, since it is shown in one. * * @param props - Component props. * @param props.url - The logo's address. * @return The finding, or nothing when the logo is fine or not read yet. */ const LogoCheck = ({ url }: { url: string }): JSX.Element | null => { const [size, setSize] = useState<{ width: number; height: number } | null>( null ); const [broken, setBroken] = useState(false); useEffect(() => { setSize(null); setBroken(false); if (url.trim() === '') { return undefined; } const image = new Image(); image.onload = () => setSize({ width: image.naturalWidth, height: image.naturalHeight }); image.onerror = () => setBroken(true); image.src = url; return () => { image.onload = null; image.onerror = null; }; }, [url]); if (broken) { return (

The image could not be loaded from this address.

); } if (!size) { return null; } const small = size.width < 112 || size.height < 112; const ratio = size.width / size.height; const square = ratio > 0.8 && ratio < 1.25; if (!small && square) { return (

{size.width} × {size.height} pixels: fine for Google.

); } return (

{size.width} × {size.height} pixels:{' '} {small ? 'Google wants at least 112 × 112.' : 'Google shows logos in a square, so a wide one is shrunk to fit; a square version reads better.'}

); }; /** * The six steps a site goes through once: what to bring over, who it * belongs to, where it is on social, what search engines may index, * which modules run and what AI engines may read. Everything is written through the settings the pages * use, so a step can be changed later in its own page. */ const SeoSetup = (): JSX.Element => { const { settings, setSettings, error, reload } = useSeoSettings(); const { refresh } = useSeoBadges(); const navigate = useNavigate(); const [step, setStep] = useState(0); const [saving, setSaving] = useState(false); const [sources, setSources] = useState(null); // Save and continue sits bottom-right, under the Copilot's bubble. useQuietLauncher(); const [titles, setTitles] = useState>({}); const [sitemapOn, setSitemapOn] = useState(true); const [modules, setModules] = useState>({}); const [kind, setKind] = useState(''); const [links, setLinks] = useState({ nofollow_external: false, new_window_external: false, }); const [llmsDescription, setLlmsDescription] = useState(''); const [files, setFiles] = useState(null); // Empty while the lists match no preset (set crawler by crawler). const [preset, setPreset] = useState(''); const [presetPicked, setPresetPicked] = useState(false); const [saved, setSaved] = useState>(new Set()); const [loaded, setLoaded] = useState(false); useEffect(() => { fetchSeoFiles() .then(read => { setFiles(read); setPreset( matchingPreset(read.robots.policy, read.robots.crawlers) ?? '' ); }) .catch(() => setFiles(null)); }, []); useEffect(() => { fetchSeoImportSources() .then(data => setSources(data.sources.filter(source => source.available))) .catch(() => setSources([])); }, []); // The fields start from the settings once; a later save hands the // settings back, and reading them again would drop what was typed in // the steps not saved yet. useEffect(() => { if (!settings || loaded) { return; } setLoaded(true); setTitles(settings.titles as unknown as Record); setSitemapOn(Boolean(settings.sitemap.enabled)); setModules( Object.fromEntries( SETUP_MODULES.map(slug => [ slug, Boolean(settings.modules[slug]?.active), ]) ) ); setLinks({ nofollow_external: Boolean(settings.general.nofollow_external), new_window_external: Boolean(settings.general.new_window_external), }); setLlmsDescription(settings.llms.description ?? ''); }, [settings, loaded]); const postTypes = useMemo( () => Object.entries(settings?.post_types ?? {}), [settings] ); if (error) { return ; } if (!settings) { return ; } const text = (key: string): string => String(titles[key] ?? ''); const flag = (key: string): boolean => Boolean(titles[key]); const set = (key: string, value: unknown): void => setTitles(current => ({ ...current, [key]: value })); const patchOf = (index: number): ISeoSettingsPatch => { switch (index) { case 1: return { titles: { knowledge_graph_type: text('knowledge_graph_type'), knowledge_graph_name: text('knowledge_graph_name'), knowledge_graph_logo: text('knowledge_graph_logo'), og_default_image: text('og_default_image'), }, }; case 2: return { titles: { social_facebook_url: text('social_facebook_url'), social_twitter: text('social_twitter'), social_profiles: text('social_profiles'), }, }; case 3: return { titles: { ...Object.fromEntries( postTypes.map(([slug]) => [ `pt_${slug}_noindex`, flag(`pt_${slug}_noindex`), ]) ), ...Object.fromEntries( Object.keys(settings.taxonomies).map(slug => [ `tax_${slug}_noindex`, flag(`tax_${slug}_noindex`), ]) ), author_noindex: flag('author_noindex'), date_noindex: flag('date_noindex'), noindex_empty_archives: flag('noindex_empty_archives'), }, sitemap: { enabled: sitemapOn }, modules: { sitemap: sitemapOn }, }; case 4: return { modules, general: links }; case 5: return { llms: { description: llmsDescription } }; default: return {}; } }; const save = async (patch: ISeoSettingsPatch): Promise => { setSaving(true); try { setSettings(await updateSeoSettings(patch)); // The AI crawlers are the robots.txt editor's lists: written there, // with the text of the file as it is. if (patch.llms && files && presetPicked && preset !== '') { const policy = presetPolicy(preset, files.robots.crawlers); setFiles(await saveSeoRobots(files.robots.content, policy)); } return true; } catch (reason) { toast.error((reason as Error).message); return false; } finally { setSaving(false); } }; // A step is written before another is shown, whichever way the merchant // goes: Save and continue, Back, or a step of the bar. A step with nothing // to write (the import) is done once passed. const goTo = async (target: number): Promise => { if (target === step) { return; } const patch = patchOf(step); if (Object.keys(patch).length > 0) { if (!(await save(patch))) { return; } setSaved(current => new Set(current).add(step)); } else if (target > step) { setSaved(current => new Set(current).add(step)); } setStep(target); }; const next = (): Promise => goTo(Math.min(STEPS.length - 1, step + 1)); const finish = async (done: boolean): Promise => { if ( !(await save({ ...(done ? patchOf(step) : {}), setup: { done: true } })) ) { return; } refresh(); toast.success( done ? 'SEO is set up. Every step lives in its own page now.' : 'Setup put aside' ); navigate('/seo'); }; const body = (): JSX.Element => { switch (step) { case 0: return sources === null ? ( ) : sources.length === 0 ? (

No Rank Math, Yoast, All in One SEO, SEOPress or Redirection data is in this database, so there is nothing to bring over. Straight on to the rest.

) : ( {sources.map(source => ( { // The steps were filled from the settings before the // import; filled again, so saving them keeps what it brought. await reload(); setLoaded(false); setSaved(current => new Set(current).add(0)); }} /> ))} ); case 1: return ( set('knowledge_graph_name', event.target.value) } className="max-w-xl" /> set('og_default_image', url)} /> ); case 2: return ( set('social_facebook_url', event.target.value) } className="max-w-xl" /> set('social_twitter', event.target.value)} onBlur={() => { const handle = xHandle(text('social_twitter')); if (handle) { set('social_twitter', handle); } }} aria-invalid={xHandle(text('social_twitter')) === null} className="max-w-xl" /> {xHandle(text('social_twitter')) === null && (

{X_HANDLE_ERROR}

)}