import React, { useCallback, useEffect, useRef, useState } from 'react'; import { LuChevronDown, LuChevronRight, LuPlusCircle, LuSparkles, } from 'react-icons/lu'; import { ExtractAssistant, ExtractModal, } from '../components/company-id/extract-modal'; import { dismissProposal as persistDismissedProposal, stripDismissedFromField, stripDismissedProposals, } from '../components/company-id/dismissed-proposals'; import { FieldRow, isEmptyValue } from '../components/company-id/field-row'; import { CompanyIdRings, RingSegmentState, } from '../components/company-id/rings'; import { ScrollCue } from '../components/company-id/scroll-cue'; import { SourceMaterialRail, UploadPhase, } from '../components/company-id/source-material-rail'; import { parseDraft, serializeValue, } from '../components/company-id/value-parsing'; import { Spinner } from '../components/ui/Spinner'; import { useAppStateContext } from '../context/user.data.context'; import { CompanyIdExtractImportResult, CompanyIdFieldEnvelope, CompanyIdPayload, CompanyIdRing, CompanyIdVisibilityPush, POPULATE_MODE, PopulateMode, } from '../service/company-id/company-id.interface'; import { actOnCompanyIdSuggestion, dispatchCompanyIdPopulate, getCompanyId, getCompanyIdCheatSheetPdf, getCompanyIdPdf, pushCompanyIdToVisibility, updateCompanyIdField, } from '../service/company-id/company-id.service'; import { fetchCompanyIdReadState } from '../service/copilot/copilot.service'; import { createTextEntry, getBatchStatus, listDocuments, pushToRag, uploadBatch, } from '../service/documentation/documentation.service'; import { getMedia, saveLogo } from '../service/popup/popup.service'; import { getSetupProgress, setSetupProgressStep, } from '../service/setup-progress/setup-progress.service'; import { WORDPRESS_STEP_KEYS } from '../service/setup-progress/setup-progress.constants'; import { DocumentFile } from '../types/documentation'; const PageLoading = (): JSX.Element => (
); const MONO = "'IBM Plex Mono', ui-monospace, monospace"; const POPULATE_POLL_INTERVAL_MS = 5000; const POPULATE_POLL_MAX_TICKS = 24; /** * How often to check whether a read this page did not start has landed. Slower * than the rail's own poll: nothing is spinning on screen, this only has to * catch up within a few seconds of a run finishing elsewhere. */ const EXTERNAL_READ_POLL_INTERVAL_MS = 8000; const KB_POLL_INTERVAL_MS = 2500; const KB_POLL_MAX_TICKS = 60; const KB_MAX_FILE_BYTES = 20 * 1024 * 1024; const CORE_RING_INDEX = 0; const unwrapXml = (value?: string[] | string): string => (Array.isArray(value) ? value[0] : value) || ''; /** * One read/push stamp as a comparable instant. A stamp that carries no zone is * already UTC on the wire, but Date would read it as local time, so tag it * before parsing. * * @param value - An ISO stamp, or null/undefined when that step never ran. * @return The epoch milliseconds as a string, or '' when there is no stamp. */ const toInstantKey = (value?: string | null): string => { if (!value) return ''; const zoned: string = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`; const parsed: number = Date.parse(zoned); return Number.isNaN(parsed) ? value : String(parsed); }; /** * The three read/push stamps as one comparable key. The full record serializes * them tz-naive ("2026-08-05T10:00:00") while the lean read-status endpoint * serializes them UTC-aware ("2026-08-05T10:00:00Z"), so the same instant * arrives as two different strings; compare the parsed instants, never the raw * text, or the watcher below sees a moved record on every single tick. * * @param source - Anything carrying the three stamps (record or read state). * @return The stamps as epoch milliseconds, joined by "|". */ const stampsKey = (source: { populated_at?: string | null; engines_read_at?: string | null; pushed_to_visibility_at?: string | null; }): string => [ toInstantKey(source.populated_at), toInstantKey(source.engines_read_at), toInstantKey(source.pushed_to_visibility_at), ].join('|'); function computeStats(rings: CompanyIdRing[]): { filled: number; total: number; pct: number; } { let filled = 0; let total = 0; rings.forEach(ring => ring.fields.forEach(field => { total += 1; // Only owner-accepted ("yours") fields count as done; an AI proposal the // merchant hasn't claimed yet is not complete. if (field.status === 'yours' && !isEmptyValue(field.value)) filled += 1; }) ); return { filled, total, pct: total ? Math.round((filled / total) * 100) : 0 }; } function ringHasData(ring: CompanyIdRing): boolean { return ring.fields.some( field => field.status !== 'empty' && !isEmptyValue(field.value) ); } const Page = (): JSX.Element => { const { token, clientId, shopData, user } = useAppStateContext(); const [payload, setPayload] = useState(null); const [documents, setDocuments] = useState([]); const [initialLoading, setInitialLoading] = useState(true); const [hover, setHover] = useState(-1); const [revealed, setRevealed] = useState(false); const [openMap, setOpenMap] = useState>({}); const [shownMap, setShownMap] = useState>({}); const [editingId, setEditingId] = useState(null); const [editSeed, setEditSeed] = useState(null); const [hotId, setHotId] = useState(null); const [toast, setToast] = useState(null); const [busyMode, setBusyMode] = useState(null); const [glow, setGlow] = useState(false); const [uploadPhase, setUploadPhase] = useState('idle'); const [pasteBusy, setPasteBusy] = useState(false); const [exportBusy, setExportBusy] = useState(false); const [pushBusy, setPushBusy] = useState(false); const [cheatSheetBusy, setCheatSheetBusy] = useState(false); const [logoBusy, setLogoBusy] = useState(false); const [extractAssistant, setExtractAssistant] = useState(null); const [pasteSubmittedCount, setPasteSubmittedCount] = useState(0); const reducedMotion = useRef(false); const toastTimer = useRef | null>(null); const pollTimer = useRef | null>(null); const kbPollTimer = useRef | null>(null); const pollBatchRef = useRef< (jobId: string, batchId: string, ticks: number) => void >(() => {}); const hasCreds = Boolean(token && clientId); const showToast = useCallback((message: string) => { setToast(message); if (toastTimer.current) clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 4200); }, []); const reloadDocuments = useCallback(async () => { if (!clientId || !token) return; try { const result = await listDocuments(clientId, token); if (!result.errors && result.data) { setDocuments(result.data.documents ?? []); } } catch { return; } }, [clientId, token]); useEffect(() => { reducedMotion.current = typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reducedMotion.current) { setRevealed(true); } else { requestAnimationFrame(() => setTimeout(() => setRevealed(true), 60)); } return () => { if (toastTimer.current) clearTimeout(toastTimer.current); if (pollTimer.current) clearTimeout(pollTimer.current); if (kbPollTimer.current) clearTimeout(kbPollTimer.current); }; }, []); useEffect(() => { if (!clientId || !token) return; let cancelled = false; (async () => { try { const fresh = await getCompanyId(clientId, token); if (cancelled) return; if (!fresh) { console.warn('getCompanyId resolved without a payload', { clientId }); return; } setPayload(stripDismissedProposals(fresh)); const open: Record = {}; fresh.rings.forEach(ring => { open[ring.index] = !( ring.collapsed_by_default || ring.hidden_by_default ); }); setOpenMap(open); } catch (error) { console.error('getCompanyId initial load', error); } finally { if (!cancelled) setInitialLoading(false); } })(); void reloadDocuments(); return () => { cancelled = true; }; }, [clientId, token, reloadDocuments]); const statsComplete = payload ? computeStats(payload.rings).pct === 100 : false; useEffect(() => { if (!statsComplete || !revealed || reducedMotion.current) return; const glowTimer = setTimeout(() => setGlow(true), 1050); return () => clearTimeout(glowTimer); }, [statsComplete, revealed]); const refreshSilently = useCallback(async () => { if (!clientId || !token) return null; try { const fresh = await getCompanyId(clientId, token, true, true); setPayload(stripDismissedProposals(fresh)); return fresh; } catch (error) { console.error('getCompanyId refreshSilently', error); return null; } }, [clientId, token]); const firstReadPending = !!payload && !payload.populated_at; useEffect(() => { if (!firstReadPending) return; let ticks = 0; let timer: ReturnType | null = null; const poll = async () => { ticks += 1; const fresh = await refreshSilently(); if (fresh?.populated_at || ticks >= POPULATE_POLL_MAX_TICKS) return; timer = setTimeout(poll, POPULATE_POLL_INTERVAL_MS); }; timer = setTimeout(poll, POPULATE_POLL_INTERVAL_MS); return () => { if (timer) clearTimeout(timer); }; }, [firstReadPending, refreshSilently]); const replaceField = useCallback((envelope: CompanyIdFieldEnvelope) => { const cleaned = stripDismissedFromField(envelope); setPayload(current => { if (!current) return current; return { ...current, rings: current.rings.map(ring => ({ ...ring, fields: ring.fields.map(field => field.id === cleaned.id ? cleaned : field ), })), }; }); }, []); const dismissAiProposal = useCallback( (field: CompanyIdFieldEnvelope, engine: string) => { const proposal = field.ai_proposals?.find( entry => entry.engine === engine ); persistDismissedProposal(field.id, engine, proposal?.value); setPayload(current => { if (!current) return current; return { ...current, rings: current.rings.map(ring => ({ ...ring, fields: ring.fields.map(entry => entry.id === field.id ? { ...entry, ai_proposals: (entry.ai_proposals ?? []).filter( candidate => candidate.engine !== engine ), } : entry ), })), }; }); }, [] ); const commitEdit = useCallback( async (field: CompanyIdFieldEnvelope, draft: string) => { setEditingId(null); setEditSeed(null); if (!clientId || !token) return; const value = parseDraft(field.kind, draft); if (value == null && isEmptyValue(field.value)) return; if ( value != null && field.status === 'yours' && draft.trim() === serializeValue(field.kind, field.value).trim() ) { return; } try { const envelope = await updateCompanyIdField( clientId, token, field.id, value ); // A merchant save is unambiguous "this is Yours" — drop the pending // proposal + AI candidates locally even if the backend echoes the // envelope with stale status, so the "Needs your decision" chip // clears immediately. replaceField({ ...envelope, status: 'yours', suggested: null, ai_proposals: [], }); if (payload?.nudge?.field_id === field.id) void refreshSilently(); } catch (saveError) { showToast( saveError instanceof Error ? saveError.message : 'Save failed' ); } }, [clientId, token, replaceField, showToast, payload, refreshSilently] ); const removeTag = useCallback( async (field: CompanyIdFieldEnvelope, index: number) => { if (!clientId || !token) return; const existing = Array.isArray(field.value) ? (field.value as string[]) : []; const remaining = existing.filter( (_, entryIndex) => entryIndex !== index ); try { const envelope = await updateCompanyIdField( clientId, token, field.id, remaining.length ? remaining : null ); replaceField(envelope); } catch (saveError) { showToast( saveError instanceof Error ? saveError.message : 'Delete failed' ); } }, [clientId, token, replaceField, showToast] ); const addTag = useCallback( async (field: CompanyIdFieldEnvelope, tag: string) => { if (!clientId || !token) return; const cleaned = tag.trim(); if (!cleaned) return; const existing = Array.isArray(field.value) ? (field.value as string[]) : []; if ( existing.some(entry => entry.toLowerCase() === cleaned.toLowerCase()) ) { showToast(`"${cleaned}" is already there`); return; } try { const envelope = await updateCompanyIdField(clientId, token, field.id, [ ...existing, cleaned, ]); replaceField(envelope); if (payload?.nudge?.field_id === field.id) void refreshSilently(); } catch (saveError) { showToast( saveError instanceof Error ? saveError.message : 'Save failed' ); } }, [clientId, token, replaceField, showToast, payload, refreshSilently] ); const resolveSuggestion = useCallback( async (fieldId: string, action: 'accept' | 'decline') => { if (!clientId || !token) return; try { const envelope = await actOnCompanyIdSuggestion( clientId, token, fieldId, action ); replaceField(envelope); } catch (suggestionError) { showToast( suggestionError instanceof Error ? suggestionError.message : 'Action failed' ); } }, [clientId, token, replaceField, showToast] ); const claimProposal = useCallback( async (field: CompanyIdFieldEnvelope, value: unknown) => { if (!clientId || !token) return; try { const envelope = await updateCompanyIdField( clientId, token, field.id, value ); replaceField(envelope); } catch (claimError) { showToast( claimError instanceof Error ? claimError.message : 'Claim failed' ); } }, [clientId, token, replaceField, showToast] ); const onUploadLogo = useCallback( async (file: File) => { if (!clientId || !token || !user) return; setLogoBusy(true); try { const formData = new FormData(); formData.append('file', file); formData.append('file_type', 'LOGO'); formData.append('model_type', 'COMPANY_ID_LOGO'); formData.append('model_id', user.id); await saveLogo(formData); const mediaRes = await getMedia(user.id, 'COMPANY_ID_LOGO'); const logoUrl = mediaRes?.media?.file_path ?? null; if (!logoUrl) { showToast('Logo upload failed. Please try again.'); return; } const envelope = await updateCompanyIdField( clientId, token, 'brand_assets', logoUrl ); replaceField(envelope); showToast('Logo saved to your Company ID.'); } catch { showToast('Logo upload failed. Please try again.'); } finally { setLogoBusy(false); } }, [clientId, token, user, replaceField, showToast] ); const scrollToSection = useCallback( (ringIndex: number, after?: () => void) => { setShownMap(current => ({ ...current, [ringIndex]: true })); setOpenMap(current => ({ ...current, [ringIndex]: true })); setTimeout(() => { const el = document.getElementById(`cid-sec-${ringIndex}`); if (el) { el.scrollIntoView({ behavior: reducedMotion.current ? 'auto' : 'smooth', block: 'start', }); } if (after) after(); }, 40); }, [] ); /** * Open a field's ring, bring the field to the middle of the screen and * flash it, so a pointer from elsewhere on the page lands on the row * itself instead of the top of a long section. * * @param {number} ringIndex - Ring holding the field. * @param {string} fieldId - Canonical field id to land on. * @param {boolean} openEditor - Also open the inline editor once landed. * Off for pointers at fields that may render an uploader or a proposal * card, where an editor is the wrong first thing to see. * @returns {void} */ const focusField = useCallback( (ringIndex: number, fieldId: string, openEditor: boolean) => { scrollToSection(ringIndex, () => { setTimeout(() => { const el = document.getElementById(`cid-f-${fieldId}`); if (el) { el.scrollIntoView({ behavior: reducedMotion.current ? 'auto' : 'smooth', block: 'center', }); } setHotId(fieldId); if (openEditor) setTimeout(() => setEditingId(fieldId), 500); setTimeout( () => setHotId(current => (current === fieldId ? null : current)), 2800 ); }, 80); }); }, [scrollToSection] ); const onNudge = useCallback(() => { if (!payload?.nudge) return; const nudgeFieldId = payload.nudge.field_id; const ring = payload.rings.find(candidate => candidate.fields.some(field => field.id === nudgeFieldId) ); if (!ring) return; focusField(ring.index, nudgeFieldId, true); }, [payload, focusField]); /** * The read/push stamps this page is currently rendering, as one comparable * string. Kept in a ref so the watcher below can tell "the record moved" * from "we already pulled that in" without restarting on every render. */ const renderedStampsRef = useRef(null); useEffect(() => { if (!payload) return; renderedStampsRef.current = stampsKey(payload); }, [payload]); const runPopulate = useCallback( async (mode: PopulateMode) => { if (busyMode !== null || !payload || !clientId || !token) return; setBusyMode(mode); const populatedBefore = payload.populated_at; // The AI research (WEB) run stamps engines_read_at, not populated_at - // only the site read moves that one. Each mode watches its own stamp, // otherwise the WEB spinner outlives the finished run. const enginesReadBefore = payload.engines_read_at; try { await dispatchCompanyIdPopulate( clientId, token, mode, mode === POPULATE_MODE.SITE ); } catch (dispatchError) { setBusyMode(null); showToast( dispatchError instanceof Error ? dispatchError.message : 'Could not start the read' ); return; } let ticks = 0; const poll = async () => { ticks += 1; const fresh = await refreshSilently(); const runDone = !!fresh && (mode === POPULATE_MODE.WEB ? fresh.engines_read_at !== enginesReadBefore || fresh.populated_at !== populatedBefore : fresh.populated_at !== populatedBefore); if (runDone) { setBusyMode(null); showToast( mode === POPULATE_MODE.WEB ? 'AI research done. Review the new proposals.' : 'Your record was re-read from the site' ); return; } if (ticks >= POPULATE_POLL_MAX_TICKS) { setBusyMode(null); showToast('Still reading in the background. Check back shortly.'); return; } pollTimer.current = setTimeout(poll, POPULATE_POLL_INTERVAL_MS); }; pollTimer.current = setTimeout(poll, POPULATE_POLL_INTERVAL_MS); }, [busyMode, payload, clientId, token, refreshSilently, showToast] ); // A read started from the copilot lands server-side with nothing on this page // watching for it: runPopulate only polls for runs the rail's own buttons // dispatched. The merchant was left reading a "Complete" card in the chat // over a record that still showed the old fields until they refreshed by // hand. Watch the lean stamps instead - they also cover a run started in // another tab, or one the signup worker finished while they sat here. useEffect(() => { if (!token || !clientId) return; let cancelled = false; let timer: ReturnType | null = null; const tick = async () => { if (cancelled) return; // While the rail is running its own read, that poll owns the refresh; // and a hidden tab has nothing to bring up to date. const shouldCheck: boolean = busyMode === null && renderedStampsRef.current !== null && (typeof document === 'undefined' || document.visibilityState === 'visible'); if (shouldCheck) { const stamps = await fetchCompanyIdReadState(token, clientId); if (stamps) { const latest: string = stampsKey(stamps); if (!cancelled && latest !== renderedStampsRef.current) { renderedStampsRef.current = latest; await refreshSilently(); // The page can unmount across that read; a toast fired now would // leave its own dismiss timer running past the cleanup above. if (!cancelled) { showToast('Your record was updated by a read that just finished'); } } } } if (!cancelled) timer = setTimeout(tick, EXTERNAL_READ_POLL_INTERVAL_MS); }; timer = setTimeout(tick, EXTERNAL_READ_POLL_INTERVAL_MS); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [busyMode, refreshSilently, showToast, token, clientId]); pollBatchRef.current = (jobId, batchId, ticks) => { if (ticks > KB_POLL_MAX_TICKS) { setUploadPhase('idle'); showToast('Still processing in the background. Check back shortly.'); return; } kbPollTimer.current = setTimeout(async () => { if (!clientId || !token) return; try { const result = await getBatchStatus(jobId, clientId, token); const status = result.data; if (status && status.state === 'SUCCESS') { setUploadPhase('pushing'); try { await pushToRag(batchId, clientId, token); setUploadPhase('idle'); showToast('Added to your knowledge base'); void reloadDocuments(); } catch { setUploadPhase('idle'); showToast('Publishing to the knowledge base failed.'); } } else if (status && status.state === 'FAILURE') { setUploadPhase('idle'); showToast('Upload failed while processing.'); } else { pollBatchRef.current(jobId, batchId, ticks + 1); } } catch { pollBatchRef.current(jobId, batchId, ticks + 1); } }, KB_POLL_INTERVAL_MS); }; const startUpload = useCallback( (files: FileList) => { if (!clientId || !token) return; const oversized = Array.from(files).find( file => file.size > KB_MAX_FILE_BYTES ); if (oversized) { showToast(`${oversized.name} is over 20 MB`); return; } const uploadFd = new FormData(); Array.from(files).forEach(file => uploadFd.append('files', file)); setUploadPhase('uploading'); (async () => { try { const result = await uploadBatch(uploadFd, clientId, token); if (result.errors || !result.data) { setUploadPhase('idle'); showToast('Upload failed. Please try again.'); return; } setUploadPhase('processing'); pollBatchRef.current(result.data.job_id, result.data.batch_id, 0); } catch { setUploadPhase('idle'); showToast('Upload failed. Please try again.'); } })(); }, [clientId, token, showToast] ); const submitPasteText = useCallback( (title: string, text: string) => { if (!clientId || !token) return; setPasteBusy(true); (async () => { try { const result = await createTextEntry( { title, description: text }, clientId, token ); if (result.errors || !result.data) { showToast('Failed to add the text entry.'); return; } setPasteSubmittedCount(count => count + 1); showToast('Text added to your knowledge base'); void reloadDocuments(); } catch { showToast('Failed to add the text entry.'); } finally { setPasteBusy(false); } })(); }, [clientId, token, showToast, reloadDocuments] ); const onExportPdf = useCallback(async () => { if (exportBusy || !clientId || !token) return; setExportBusy(true); try { const blob = await getCompanyIdPdf(clientId, token); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `company-id-${(payload?.domain || 'record').replace(/\./g, '-')}.pdf`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } catch (exportError) { showToast( exportError instanceof Error ? exportError.message : 'Export failed' ); } finally { setExportBusy(false); } }, [clientId, token, payload, exportBusy, showToast]); const onPushToVisibility = useCallback(async (): Promise => { if (pushBusy || !clientId || !token) return null; setPushBusy(true); try { const result = await pushCompanyIdToVisibility(clientId, token); if (result.synced) { // Sequential, never parallel: the API rewrites the whole // setup_progress blob per call, so two writes in flight lose one of // the two steps. The push also queues a forced Visibility scan, so // it satisfies the run step as well. setSetupProgressStep(WORDPRESS_STEP_KEYS.COMPANY_ID, true) .then(() => setSetupProgressStep(WORDPRESS_STEP_KEYS.RUN_AI_VISIBILITY, true) ) .catch(() => {}); } // A synced push is confirmed by the rail's modal; only empty and // failure cases fall back to a toast. if (!result.synced) { if (result.reason === 'no_brand') { showToast( 'Could not set up your AI Visibility brand. Please try again.' ); } else { // "Nothing to sync" with a filled-looking page means the record // shown here is stale (e.g. reset elsewhere); re-read it so the // fields reflect reality before the merchant retries. await refreshSilently(); showToast( 'Nothing to push yet. Fill what you sell and your offerings first.' ); } } return result; } catch (pushError) { showToast( pushError instanceof Error ? pushError.message : 'Push failed' ); return null; } finally { setPushBusy(false); } }, [clientId, token, pushBusy, showToast, refreshSilently]); // Retro-flip the Company ID setup step for records pushed to Visibility // before the step existed; the push handler stamps it going forward. useEffect(() => { if (!payload?.pushed_to_visibility_at) return; getSetupProgress() .then(async progress => { if (!progress) return; // The push satisfies both steps. Test each key on its own so a // half-written pair still heals, and write them sequentially. if (!progress.steps[WORDPRESS_STEP_KEYS.COMPANY_ID]?.completed) { await setSetupProgressStep(WORDPRESS_STEP_KEYS.COMPANY_ID, true); } if (!progress.steps[WORDPRESS_STEP_KEYS.RUN_AI_VISIBILITY]?.completed) { await setSetupProgressStep( WORDPRESS_STEP_KEYS.RUN_AI_VISIBILITY, true ); } }) .catch(() => {}); }, [payload?.pushed_to_visibility_at]); const onDownloadCheatSheet = useCallback(async () => { if (cheatSheetBusy || !clientId || !token) return; setCheatSheetBusy(true); try { const blob = await getCompanyIdCheatSheetPdf(clientId, token); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'company-id-cheat-sheet.pdf'; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); } catch (cheatSheetError) { showToast( cheatSheetError instanceof Error ? cheatSheetError.message : 'Download failed' ); } finally { setCheatSheetBusy(false); } }, [clientId, token, cheatSheetBusy, showToast]); const onExtractImported = useCallback( (result: CompanyIdExtractImportResult) => { setExtractAssistant(null); const landed = result.proposed.length + result.suggested.length; showToast( landed > 0 ? `Imported ${landed} field${landed === 1 ? '' : 's'} as proposals. Review them below.` : 'Nothing usable found in the pasted export.' ); void refreshSilently(); }, [showToast, refreshSilently] ); if (!hasCreds || initialLoading) { return ; } if (!payload) { return (
Company ID could not be loaded. Refresh to retry.
); } const stats = computeStats(payload.rings); const isComplete = stats.pct === 100; const hasAiProposals = payload.rings.some(ring => ring.fields.some(field => (field.ai_proposals ?? []).length > 0) ); // Step 2 done: the record's engines-read stamp survives claimed/dismissed // proposals; waiting proposals count too (pre-stamp records, mid-run). const enginesReadDone = !!payload.engines_read_at || hasAiProposals; const coreRing = payload.rings.find(ring => ring.index === CORE_RING_INDEX); // The Core fields still standing between the merchant and step 3. Kept as // the fields themselves, not a count: the rail shows how many are left and // the step jumps straight at the first one, because "6 of 7" on its own // never told anyone WHICH answer was missing. const coreGaps = (coreRing?.fields ?? []).filter( field => field.status !== 'yours' || isEmptyValue(field.value) ); const coreComplete = !!coreRing && coreGaps.length === 0; const pushedOnce = !!payload.pushed_to_visibility_at; const canDownloadCheatSheet = payload.entitlements?.can_download_cheat_sheet ?? false; const canExtract = payload.entitlements?.can_extract ?? false; // Tri-state segments: claimed fields at full accent, unclaimed proposals as // a pale accent (there IS something here, but it's not done), empty as grey. // Keeps the rings agreeing with computeStats, which counts only "yours". const fills = payload.rings.map(ring => ring.fields.map( (field): RingSegmentState => field.status === 'empty' || isEmptyValue(field.value) ? 'empty' : field.status === 'yours' ? 'yours' : 'proposed' ) ); const canonicalField = payload.rings[0]?.fields.find( field => field.id === 'canonical_name' ); const canonicalName = canonicalField && !isEmptyValue(canonicalField.value) ? String(canonicalField.value) : ''; const shopName = unwrapXml(shopData?.data?.store?.name); const shopDomain = unwrapXml(shopData?.data?.store?.domain); const displayName = canonicalName || shopName; const domainLabel = payload.domain || shopDomain || 'your store'; const headerSub = displayName ? `${displayName} · ${domainLabel}` : `${domainLabel} · just installed`; const centerLine1 = displayName || 'Core ID'; const centerLine2 = displayName ? 'Company ID' : 'add your name'; const animate = revealed && !reducedMotion.current; return (
Settings · Company ID

Company ID

{headerSub}
{payload.populated_at ? 'Synced · agent, scan and content read from here' : 'First read queued'}
{glow && (
)} setHover(-1)} onRingClick={ringIndex => scrollToSection(ringIndex)} />
{centerLine1}
{centerLine2}
Company ID completeness
{stats.pct} %
{stats.filled} of {stats.total} fields
{payload.nudge && ( )}
{payload.rings.map(ring => { const total = ring.fields.length; const filledCount = ring.fields.filter( field => field.status === 'yours' && !isEmptyValue(field.value) ).length; const hovered = hover === ring.index; return ( ); })}
{payload.rings.map(ring => { const total = ring.fields.length; const filledCount = ring.fields.filter( field => field.status === 'yours' && !isEmptyValue(field.value) ).length; const isStub = ring.hidden_by_default && !shownMap[ring.index] && !ringHasData(ring); const isOpen = !!openMap[ring.index] && !isStub; return (
{isStub ? (
{ring.index} {ring.name} {filledCount} of {total} {ring.hidden_note}
) : ( <> {isOpen && (
{ring.fields.map(field => ( { setEditingId(fieldId); setEditSeed(seed ?? null); }} onCancelEdit={() => { setEditingId(null); setEditSeed(null); }} onCommit={commitEdit} onAddTag={addTag} onRemoveTag={removeTag} onSuggestion={resolveSuggestion} onClaimProposal={claimProposal} onDismissProposal={dismissAiProposal} onUploadLogo={onUploadLogo} logoBusy={logoBusy} searching={busyMode === POPULATE_MODE.WEB} onToast={showToast} /> ))}
)} )}
); })}
{ const gap = coreGaps[0]; if (gap) focusField(CORE_RING_INDEX, gap.id, false); else scrollToSection(CORE_RING_INDEX); }} canDownloadCheatSheet={canDownloadCheatSheet} canExtract={canExtract} cheatSheetBusy={cheatSheetBusy} onDownloadCheatSheet={onDownloadCheatSheet} onOpenExtract={setExtractAssistant} />
{toast && (
{toast}
)} {extractAssistant && clientId && token && ( setExtractAssistant(null)} onImported={onExtractImported} onToast={showToast} /> )}
); }; export default Page;