/** * The Company ID action rail. Top card: the four numbered setup steps toward * a profitable niche, the Recomaze Copilot pointer and the fast-track actions * (cheat sheet download, extract from Claude / ChatGPT). Bottom card: the * Source material the record was learned from, with inline Upload / Paste and * the share (PDF) export. */ import { Link } from 'react-router-dom'; import React, { useEffect, useRef, useState } from 'react'; import { LuCheck, LuClipboardPaste, LuEye, LuFileText, LuListChecks, LuLock, LuRefreshCw, LuShare2, LuSparkles, LuUpload, } from 'react-icons/lu'; import { ChatGptLogo } from '../icons/chatgpt-logo'; import { ClaudeLogo } from '../icons/claude-logo'; import { CompanyIdVisibilityPush } from '../../service/company-id/company-id.interface'; import { DocumentFile } from '../../types/documentation'; import { EXTRACT_ASSISTANT, ExtractAssistant } from './extract-modal'; import { agoLabel } from './field-row'; import { MODAL_GHOST_BUTTON_CLASS, MODAL_PRIMARY_BUTTON_CLASS, } from './modal-buttons'; /** Join truthy class fragments; local since the repo has no cn helper. */ const cx = (...parts: Array): string => parts.filter(Boolean).join(' '); const RAIL_DOCUMENTS_SHOWN = 4; const EXTRACT_LOCK_HINT = 'Available on paid plans starting at $39'; const EXTRACT_FAST_TRACKS = [ { assistant: EXTRACT_ASSISTANT.CLAUDE, icon: , label: 'Extract it from your Claude', }, { assistant: EXTRACT_ASSISTANT.CHATGPT, icon: , label: 'Extract it from your ChatGPT', }, ] as const; export type UploadPhase = 'idle' | 'uploading' | 'processing' | 'pushing'; const UPLOAD_LABELS: Record = { idle: 'Upload', uploading: 'Uploading...', processing: 'Processing...', pushing: 'Publishing...', }; const CARD_CLASS = 'rounded-[14px] border border-[#e1e3e5] bg-white'; const KICKER_CLASS = 'font-mono text-[10.5px] font-medium uppercase tracking-[.08em] text-[#202223]'; const STEP_BUTTON_CLASS = 'flex min-h-[38px] w-full cursor-pointer items-center gap-2 rounded-[10px] border border-[#e1e3e5] bg-[#f6f6f7] px-3 text-left text-[13px] font-medium text-[#202223] disabled:cursor-not-allowed disabled:bg-[#f6f6f7] disabled:text-[#a1a3a5] disabled:border-[#eaeaea]'; // The CURRENT step carries the gradient fill (the highlight travels with the // merchant's progress instead of sitting on step 2 forever). const STEP_BUTTON_CURRENT = 'border-transparent text-white [background:linear-gradient(135deg,#C8175D,#7B3FE4)]'; const FAST_TRACK_CLASS = 'flex min-h-[38px] w-full cursor-pointer items-center gap-2 rounded-[10px] border border-[#e1e3e5] bg-transparent px-3 text-left text-[13px] text-[#4a4d4f] no-underline disabled:opacity-70'; // Labels WRAP inside their pill rather than truncate: the rail is a fixed // 300px and "What Gemini and ChatGPT say" - the step merchants understand // least - lost its last word to an ellipsis. min-w-0 keeps the trailing lock // from being pushed past the rounded border. const ACTION_LABEL_CLASS = 'min-w-0 flex-1 leading-snug'; const DASHED_BUTTON_CLASS = 'flex h-8 flex-1 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-[9px] border border-dashed border-[#c9cccf] bg-transparent text-xs text-[#6d7175]'; const RAIL_INPUT_CLASS = 'box-border w-full rounded-lg border border-[#e1e3e5] bg-white px-2.5 py-2 text-[12.5px] leading-normal text-[#202223] outline-none'; interface SourceMaterialRailProps { domain: string; populatedAt: string | null; documents: DocumentFile[]; uploadPhase: UploadPhase; onUploadFiles: (files: FileList) => void; onPasteText: (title: string, text: string) => void; pasteSubmittedCount: number; kbBusy: boolean; exportBusy: boolean; onExportPdf: () => void; busyMode: 'site' | 'web' | null; onPopulate: (mode: 'site' | 'web') => void; pushBusy: boolean; onPushToVisibility: () => Promise; /** Step 2 done: a "Search with AI" read completed at least once (record * stamp), or engine proposals are currently waiting. */ enginesReadDone: boolean; /** Step 3 done: every Core ring field is claimed as Yours. */ coreComplete: boolean; /** Core fields still unclaimed, shown on step 3 so "not done yet" comes * with a number instead of leaving the merchant hunting the ring. */ coreRemaining: number; pushedOnce: boolean; /** Jump at the first Core answer still missing (step 3). */ onGoToCore: () => void; canDownloadCheatSheet: boolean; canExtract: boolean; cheatSheetBusy: boolean; onDownloadCheatSheet: () => void; onOpenExtract: (assistant: ExtractAssistant) => void; } function railDocuments(documents: DocumentFile[]): DocumentFile[] { const ranked = [...documents].sort((first, second) => { const manualDelta = Number(second.source !== 'auto') - Number(first.source !== 'auto'); if (manualDelta !== 0) return manualDelta; return (second.processed_at || '').localeCompare(first.processed_at || ''); }); return ranked.slice(0, RAIL_DOCUMENTS_SHOWN); } /** * Stepper badge in the left gutter: a green check once the step is done * (the step stays rerunnable), the brand-filled number on the current step, * an outlined faint number while the step is locked. Sitting outside the * button, it keeps full contrast even when a locked button renders dimmed - * the numbered chain is what tells the merchant this is a one-way sequence. * * @param {{ number: number; done: boolean; current: boolean }} props - The * step's one-based position and state. * @returns {JSX.Element} The badge element. */ function StepBadge({ number, done, current, }: { number: number; done: boolean; current: boolean; }): JSX.Element { if (done) { return ( ); } if (current) { return ( {number} ); } return ( {number} ); } interface FastTrackActionProps { unlocked: boolean; lockHint: string; onRun: () => void; busy: boolean; icon: React.ReactNode; label: string; } function FastTrackAction({ unlocked, lockHint, onRun, busy, icon, label, }: FastTrackActionProps) { if (!unlocked) { return ( {icon} {label} ); } return ( ); } export function SourceMaterialRail({ domain, populatedAt, documents, uploadPhase, onUploadFiles, onPasteText, pasteSubmittedCount, kbBusy, exportBusy, onExportPdf, busyMode, onPopulate, pushBusy, onPushToVisibility, enginesReadDone, coreComplete, coreRemaining, pushedOnce, onGoToCore, canDownloadCheatSheet, canExtract, cheatSheetBusy, onDownloadCheatSheet, onOpenExtract, }: SourceMaterialRailProps) { const fileInput = useRef(null); const [pasteOpen, setPasteOpen] = useState(false); const [pasteTitle, setPasteTitle] = useState(''); const [pasteBody, setPasteBody] = useState(''); const [pushPhase, setPushPhase] = useState<'closed' | 'confirm' | 'done'>( 'closed' ); const [pushResult, setPushResult] = useState( null ); useEffect(() => { if (pasteSubmittedCount > 0) { setPasteOpen(false); setPasteTitle(''); setPasteBody(''); } }, [pasteSubmittedCount]); const learnedLine = populatedAt ? `Learned from ${domain}${ documents.length ? ` and ${documents.length} knowledge document${documents.length === 1 ? '' : 's'}` : '' }. Last read ${agoLabel(populatedAt)}.` : 'Nothing read yet. The first read is queued.'; const siteReadBusy = busyMode === 'site'; const engineReadBusy = busyMode === 'web'; const pasteSaveDisabled = kbBusy || !pasteTitle.trim() || !pasteBody.trim(); const confirmPush = async () => { const result = await onPushToVisibility(); if (result && result.synced) { setPushResult(result); setPushPhase('done'); } else { setPushPhase('closed'); } }; const runSiteRead = () => { if (busyMode === null) onPopulate('site'); }; const runEngineRead = () => { if (busyMode === null) onPopulate('web'); }; const openPushConfirm = () => { if (!pushBusy) setPushPhase('confirm'); }; const closePushModal = () => { if (!pushBusy) setPushPhase('closed'); }; const openFilePicker = () => { if (uploadPhase === 'idle') fileInput.current?.click(); }; const handleFilesSelected = (event: React.ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { onUploadFiles(event.target.files); event.target.value = ''; } }; const togglePasteOpen = () => setPasteOpen(open => !open); const savePastedText = () => { if (!pasteSaveDisabled) onPasteText(pasteTitle.trim(), pasteBody.trim()); }; // Step gating: block an UNDONE step past the current (first undone) one so // a new merchant progresses in order. Done steps stay clickable (rerun) // even when a later gap reopens an earlier step; the current step carries // the gradient highlight. const stepDone = [!!populatedAt, enginesReadDone, coreComplete, pushedOnce]; const currentStepIndex = stepDone.findIndex(done => !done); const stepsDoneCount = stepDone.filter(Boolean).length; const allStepsDone = currentStepIndex === -1; /** * Whether a step is not yet reachable (undone, and not the current one). * * @param {number} index - Zero-based step index. * @returns {boolean} True when the step renders locked. */ const isStepBlocked = (index: number): boolean => !stepDone[index] && index !== currentStepIndex; /** * Whether a step is the one to take next. * * @param {number} index - Zero-based step index. * @returns {boolean} True on the first undone step. */ const isStepCurrent = (index: number): boolean => index === currentStepIndex; /** * Connector-line class for the segment between a step and the next one. * Green when EITHER end is done, not just the upper one: the four states * are independent record facts, so a later step can be done before an * earlier one (the push runs automatically on signup), and a grey line * feeding a green check read as a broken chain. * * @param {number} index - Zero-based index of the upper step. * @returns {string} The segment class list. */ const connectorClass = (index: number): string => stepDone[index] || stepDone[index + 1] ? 'bg-[#16a34a]/50' : 'bg-[#e1e3e5]'; // Until both reads are behind them the merchant is still meeting this page, // so the current step breathes instead of only carrying a gradient border: // on a first visit that is the website read, and the moment it lands the // pulse moves to the engines read, which is the one nobody notices. const readsPending: boolean = !stepDone[0] || !stepDone[1]; const stepAttentionClass = (index: number): string => isStepCurrent(index) && readsPending ? 'company-id-step-attention' : ''; const lockedTitle = 'Finish the previous step first'; // The four setup steps as data, so the stepper gutter (badge + connector // line) renders from one loop and the chain stays visually continuous. const steps: { key: string; onClick: () => void; disabled: boolean; dimmed: boolean; title?: string; icon: React.ReactNode; label: string; /** Optional badge pinned to the right edge of the step button. */ trailer?: React.ReactNode; }[] = [ { key: 'site', onClick: runSiteRead, disabled: busyMode !== null || isStepBlocked(0), dimmed: engineReadBusy, title: isStepBlocked(0) ? lockedTitle : undefined, icon: ( ), label: siteReadBusy ? 'Reading your site...' : 'What your website says', }, { key: 'engines', onClick: runEngineRead, disabled: busyMode !== null || isStepBlocked(1), dimmed: siteReadBusy, title: isStepBlocked(1) ? lockedTitle : undefined, icon: ( ), label: engineReadBusy ? 'Searching with AI...' : 'What Gemini and ChatGPT say', }, { key: 'core', onClick: onGoToCore, disabled: isStepBlocked(2), dimmed: false, title: isStepBlocked(2) ? lockedTitle : coreRemaining > 0 ? 'Jump to the first Core answer still missing' : undefined, icon: , label: 'Fill the Core ring at least', // The ring counter says "6 of 7" but never which answer is missing, so // merchants read the ring as finished and the step as broken. The // badge names the gap and the click lands on it. trailer: coreRemaining > 0 && !isStepBlocked(2) ? ( {coreRemaining} left ) : null, }, { key: 'push', onClick: openPushConfirm, disabled: pushBusy || isStepBlocked(3), dimmed: pushBusy, title: isStepBlocked(3) ? lockedTitle : 'Fill your AI Visibility scan settings from this record', icon: ( ), label: pushBusy ? 'Pushing...' : 'Push to AI Visibility', }, ]; return (
{/* The counter can drop to its own line: at 300px the kicker fills the row on its own, and wrapping it would orphan "NICHE". */}
Set up your profitable niche
0 ? 'text-[#16a34a]' : 'text-[#6d7175]' )} > {stepsDoneCount}/4 DONE
{allStepsDone ? ( <> All four steps are done - your niche is set up.{' '} Rerun any of them whenever your site or your story changes. ) : ( <> To set up a profitable niche in AI, fill at least the Core ring of your Company ID.{' '} Take the four steps from the top {' '} - each one unlocks the next: )}
    {steps.map((step, index) => (
  1. {/* Stepper gutter: numbered badge plus the connector line that turns green as steps complete. It sits outside the buttons so locked rows can dim without the sequence itself ever fading. */}
  2. ))}
Steps with a green check ran automatically when your account was created. {' '} You can rerun any finished step whenever your site or story changes.
Questions? Open and ask Recomaze Copilot.
Want to update everything perfectly or super fast?
} label={ cheatSheetBusy ? 'Preparing...' : 'Download Company ID Cheat Sheet' } /> {EXTRACT_FAST_TRACKS.map(({ assistant, icon, label }) => ( onOpenExtract(assistant)} busy={false} icon={icon} label={label} /> ))}
Proposed values are extracted from your own website and knowledge base - what your site doesn't state stays empty. What Gemini and ChatGPT say researches the public web and proposes every field it can verify. Push to AI Visibility fills your scan settings (niche, tags, country, city, knowledge base) from this record. Fields you have edited are never overwritten: new ideas wait for your call.
Source material
{learnedLine}
{railDocuments(documents).map(document => (
{document.file_name} {document.processed_at ? agoLabel(document.processed_at) : ''}
))}
{pasteOpen && (
setPasteTitle(event.target.value)} placeholder="Title, e.g. Shipping FAQ" className={RAIL_INPUT_CLASS} />