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;