Business page visual, view brand buttons, and other stuff

This commit is contained in:
Ritul Jadhav 2026-04-03 12:14:02 +05:30
parent 6bbfc1b740
commit 93e0b32454
12 changed files with 1894 additions and 512 deletions

View File

@ -1,5 +1,8 @@
import { useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { BusinessProvider, useBusiness } from './context/BusinessContext'; import { BusinessProvider, useBusiness } from './context/BusinessContext';
import apiClient from './api/client';
import BusinessReviewModal from './components/BusinessReviewModal';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Businesses from './pages/Businesses'; import Businesses from './pages/Businesses';
import Providers from './pages/Providers'; import Providers from './pages/Providers';
@ -9,10 +12,40 @@ import Templates from './pages/Templates';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
function SubLayout({ children }) { function SubLayout({ children }) {
const { activeBusinessId, hasGlobalSms } = useBusiness(); const { activeBusiness, activeBusinessId, hasGlobalSms } = useBusiness();
const [reviewBusiness, setReviewBusiness] = useState(null);
const [reviewLoading, setReviewLoading] = useState(false);
const [reviewError, setReviewError] = useState('');
async function handleOpenReview() {
if (!activeBusinessId || reviewLoading) return;
setReviewError('');
if (activeBusiness?.scrapeArtifacts?.json) {
setReviewBusiness(activeBusiness);
return;
}
setReviewLoading(true);
try {
const response = await apiClient.get(`/api/businesses/${activeBusinessId}`);
setReviewBusiness(response.data);
} catch (error) {
setReviewError(error.response?.data?.error || 'Failed to load brand review.');
} finally {
setReviewLoading(false);
}
}
return ( return (
<>
<div className="flex min-h-screen bg-page-bg"> <div className="flex min-h-screen bg-page-bg">
<Sidebar /> <Sidebar
onOpenReview={handleOpenReview}
reviewLoading={reviewLoading}
reviewError={reviewError}
/>
<main className="flex-1 ml-60 flex flex-col"> <main className="flex-1 ml-60 flex flex-col">
<header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0"> <header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
{hasGlobalSms && ( {hasGlobalSms && (
@ -30,6 +63,14 @@ function SubLayout({ children }) {
</div> </div>
</main> </main>
</div> </div>
{reviewBusiness && (
<BusinessReviewModal
business={reviewBusiness}
onClose={() => setReviewBusiness(null)}
/>
)}
</>
); );
} }

View File

@ -0,0 +1,354 @@
import { useEffect, useMemo } from 'react';
import CdnGallery from './CdnGallery';
import {
getBusinessDomain,
getBusinessImage,
getBusinessName,
getBusinessTagline,
} from '../utils/businessProfile';
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeUniqueStrings(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((entry) => normalizeText(entry))
.filter((entry) => {
if (!entry || seen.has(entry)) return false;
seen.add(entry);
return true;
});
}
function normalizeColorEntries(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((entry, index) => {
if (typeof entry === 'string') {
const hex = normalizeText(entry);
return hex ? { name: '', hex, key: `${hex}:${index}` } : null;
}
if (!entry || typeof entry !== 'object') return null;
const hex = normalizeText(entry.hex || entry.value || entry.color);
if (!hex) return null;
const name = normalizeText(entry.name || entry.label || entry.role);
return { name, hex, key: `${name}:${hex}` };
})
.filter((entry) => {
if (!entry || seen.has(entry.key)) return false;
seen.add(entry.key);
return true;
});
}
function extractCdnUrls(business) {
const artifactUrls = business?.scrapeArtifacts?.cdnUrls;
if (Array.isArray(artifactUrls) && artifactUrls.length > 0) {
return normalizeUniqueStrings(artifactUrls);
}
return normalizeUniqueStrings(business?.relevantImagePaths);
}
function normalizeScrapeLinks(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((entry) => {
if (typeof entry === 'string') {
const href = normalizeText(entry);
return href ? { href, label: href } : null;
}
if (!entry || typeof entry !== 'object') return null;
const href = normalizeText(entry.href || entry.url || entry.link);
if (!href) return null;
const label = normalizeText(entry.text || entry.title || entry.label || href);
return { href, label };
})
.filter((entry) => {
if (!entry || seen.has(entry.href)) return false;
seen.add(entry.href);
return true;
});
}
function formatPrettyJson(value) {
if (value == null) return '';
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function extractColors(business) {
const labeledColors = normalizeColorEntries(business?.scrapeArtifacts?.json?.branding?.labeledColors);
if (labeledColors.length > 0) return labeledColors;
const directColors = normalizeColorEntries(business?.colors);
if (directColors.length > 0) return directColors;
const brandingColors = business?.scrapeArtifacts?.json?.branding?.colors;
return normalizeColorEntries(brandingColors);
}
function extractAboutText(business) {
const directSummary = normalizeText(business?.aboutSummary);
if (directSummary) return directSummary;
const scrapeJson = business?.scrapeArtifacts?.json;
const aboutExcerpt = normalizeText(scrapeJson?.aboutPage?.excerpt);
if (aboutExcerpt) return aboutExcerpt;
const representativeAbout = Array.isArray(scrapeJson?.representativeTextBlocks)
? scrapeJson.representativeTextBlocks.find((block) => normalizeText(block?.pageType) === 'about')
: null;
const representativeAboutText = normalizeText(representativeAbout?.text);
if (representativeAboutText) return representativeAboutText;
const homepageExcerpt = normalizeText(scrapeJson?.homepage?.excerpt);
if (homepageExcerpt) return homepageExcerpt;
return normalizeText(scrapeJson?.summaryText);
}
export default function BusinessReviewModal({ business, onClose }) {
const name = getBusinessName(business);
const domain = getBusinessDomain(business);
const tagline = getBusinessTagline(business);
const image = getBusinessImage(business);
const tone = normalizeText(business?.tone);
const taglines = normalizeUniqueStrings(business?.taglines);
const colors = extractColors(business);
const aboutText = extractAboutText(business);
const cdnUrls = extractCdnUrls(business);
const links = normalizeScrapeLinks(business?.scrapeArtifacts?.links);
const prettyJson = useMemo(() => formatPrettyJson(business?.scrapeArtifacts?.json), [business]);
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;
const previousBodyOverscroll = document.body.style.overscrollBehavior;
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousHtmlOverscroll = document.documentElement.style.overscrollBehavior;
document.body.style.overflow = 'hidden';
document.body.style.overscrollBehavior = 'none';
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overscrollBehavior = 'none';
return () => {
document.body.style.overflow = previousBodyOverflow;
document.body.style.overscrollBehavior = previousBodyOverscroll;
document.documentElement.style.overflow = previousHtmlOverflow;
document.documentElement.style.overscrollBehavior = previousHtmlOverscroll;
};
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div
className="flex h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl"
style={{ width: '920px', maxWidth: 'calc(100vw - 2rem)', height: 'min(78vh, 760px)' }}
>
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Business review</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{name}</h2>
<p className="mt-1 text-sm text-gray-500">
{domain
? `Review the captured storefront context for ${domain}.`
: 'Review the captured storefront context before moving on.'}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-50 hover:text-gray-900"
>
Close
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-5 pb-2">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-gray-200 bg-white">
{image ? (
<img src={image} alt={name} className="h-full w-full object-cover" />
) : (
<span className="text-xl font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'B'}</span>
)}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold tracking-tight text-gray-900">{name}</p>
{domain && <p className="mt-1 break-all text-sm font-medium text-gray-500">{domain}</p>}
{tagline && <p className="mt-2 text-sm leading-relaxed text-gray-700">{tagline}</p>}
<div className="mt-4 flex flex-wrap gap-2">
{tone && (
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold capitalize text-gray-600">
Tone: {tone}
</span>
)}
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
</span>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
{links.length} link{links.length === 1 ? '' : 's'}
</span>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
{colors.length} color{colors.length === 1 ? '' : 's'}
</span>
</div>
</div>
</div>
</div>
{aboutText && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">About Company</p>
<p className="mt-1 text-sm text-gray-500">A concise summary of what the brand is about, what it sells, and its overall vibe.</p>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<div className="max-h-44 overflow-y-auto px-4 py-4">
<p className="whitespace-pre-line text-sm leading-relaxed text-gray-700">{aboutText}</p>
</div>
</div>
</section>
)}
{(taglines.length > 0 || colors.length > 0) && (
<section className="grid gap-5 md:grid-cols-2">
{taglines.length > 0 && (
<div className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Taglines</p>
<p className="mt-1 text-sm text-gray-500">Short brand lines captured during onboarding.</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-4">
<div className="space-y-2">
{taglines.map((entry, index) => (
<p key={`${entry}-${index}`} className="text-sm text-gray-700">"{entry}"</p>
))}
</div>
</div>
</div>
)}
{colors.length > 0 && (
<div className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Color Codes</p>
<p className="mt-1 text-sm text-gray-500">Detected brand colors used across the storefront.</p>
</div>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-4">
<div className="flex flex-wrap gap-3">
{colors.map((color) => (
<div key={color.key} className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2.5 py-2">
<span
className="h-6 w-6 rounded-md border border-gray-200"
style={{ backgroundColor: color.hex }}
title={color.name ? `${color.name}: ${color.hex}` : color.hex}
/>
<div className="flex flex-col">
{color.name && (
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
{color.name}
</span>
)}
<span className="font-mono text-xs text-gray-600">{color.hex}</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</section>
)}
{cdnUrls.length > 0 && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Images</p>
<p className="mt-1 text-sm text-gray-500">Captured storefront images are available below.</p>
</div>
<CdnGallery urls={cdnUrls} compact showLabels={false} />
</section>
)}
{prettyJson && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Captured Data</p>
<p className="mt-1 text-sm text-gray-500">Raw storefront data captured during onboarding.</p>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
{prettyJson}
</pre>
</div>
</section>
)}
{links.length > 0 && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Links</p>
<p className="mt-1 text-sm text-gray-500">Every discovered storefront link is available below.</p>
</div>
<div className="rounded-xl border border-gray-200">
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
{links.map((link, index) => (
<a
key={`${link.href}-${index}`}
href={link.href}
target="_blank"
rel="noreferrer"
className="block px-4 py-3 transition hover:bg-gray-50"
>
<p className="break-all text-sm font-medium text-gray-800">{link.label}</p>
<p className="mt-1 break-all text-xs text-primary-blue">{link.href}</p>
</a>
))}
</div>
</div>
</section>
)}
</div>
</div>
<div className="shrink-0 border-t border-gray-200 px-6 py-4">
<button
onClick={onClose}
className="w-full rounded-lg bg-primary-blue py-2 text-sm font-medium text-white transition hover:bg-primary-dark"
>
Continue
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
export default function CdnGallery({ urls, compact = false, showLabels = true, clickable = true }) {
if (!urls.length) return null;
return (
<div className={`grid gap-3 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'}`}>
{urls.map((url, index) => {
const Wrapper = clickable ? 'a' : 'div';
const wrapperProps = clickable
? { href: url, target: '_blank', rel: 'noreferrer' }
: {};
return (
<Wrapper
key={`${url}-${index}`}
{...wrapperProps}
className={`group overflow-hidden rounded-xl border border-gray-200 bg-white transition ${clickable ? 'hover:border-primary-blue' : ''}`}
>
<div className={`bg-gray-50 ${compact ? 'aspect-[4/3]' : 'aspect-[5/4]'}`}>
<img
src={url}
alt={`Storefront image ${index + 1}`}
className="h-full w-full object-cover"
onError={(event) => {
event.currentTarget.style.opacity = '0.35';
}}
/>
</div>
{showLabels && (
<div className="border-t border-gray-100 px-3 py-2">
<p className="text-xs text-gray-500 break-all leading-relaxed line-clamp-3">{url}</p>
</div>
)}
</Wrapper>
);
})}
</div>
);
}

View File

@ -1,45 +1,130 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import apiClient from '../api/client'; import {
fetchBusinessOnboardingJob,
getBusinessOnboardingError,
getBusinessOnboardingProgress,
getBusinessOnboardingStageMeta,
shouldRetryMissingBusinessOnboardingJob,
startBusinessOnboardingJob,
} from '../utils/businessOnboarding';
export default function RegisterBusinessModal({ onClose, onSuccess }) { export default function RegisterBusinessModal({ onClose, onSuccess, onJobStarted, showCompletionScreen = true }) {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [status, setStatus] = useState('idle'); const [status, setStatus] = useState('idle');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [job, setJob] = useState(null);
const pollTimerRef = useRef(null);
const cancelledRef = useRef(false);
async function handleSubmit(e) { useEffect(() => {
e.preventDefault(); return () => {
if (!url.trim()) return; cancelledRef.current = true;
if (pollTimerRef.current) window.clearTimeout(pollTimerRef.current);
};
}, []);
setStatus('loading'); function clearPolling() {
setError(''); if (pollTimerRef.current) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}
function schedulePoll(initialJob) {
clearPolling();
let transientNotFoundMisses = 0;
let currentJob = initialJob;
const tick = async () => {
try { try {
const res = await apiClient.post('/api/businesses', { const nextJob = await fetchBusinessOnboardingJob(initialJob.jobId);
websiteUrl: url.trim(), if (cancelledRef.current) return;
});
if (typeof onSuccess === 'function') { transientNotFoundMisses = 0;
await onSuccess(res.data); currentJob = nextJob;
setJob(nextJob);
if (nextJob.status === 'completed') {
clearPolling();
if (typeof onSuccess === 'function' && nextJob.business) {
await Promise.resolve(onSuccess(nextJob.business));
}
if (showCompletionScreen && nextJob.business) {
setStatus('success');
}
return; return;
} }
setStatus('success'); if (nextJob.status === 'failed') {
clearPolling();
setStatus('error');
return;
}
pollTimerRef.current = window.setTimeout(tick, 2200);
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Something went wrong. Please try again.'); if (cancelledRef.current) return;
if (shouldRetryMissingBusinessOnboardingJob(currentJob, err, transientNotFoundMisses)) {
transientNotFoundMisses += 1;
pollTimerRef.current = window.setTimeout(tick, 1800);
return;
}
clearPolling();
setStatus('error');
setError(err.response?.data?.error || err.message || 'Failed to fetch onboarding progress.');
}
};
pollTimerRef.current = window.setTimeout(tick, 2500);
}
async function handleSubmit(e) {
e.preventDefault();
const normalizedUrl = url.trim();
if (!normalizedUrl) return;
setStatus('starting');
setError('');
try {
const startedJob = await startBusinessOnboardingJob({
websiteUrl: normalizedUrl,
});
if (typeof onJobStarted === 'function') {
await Promise.resolve(onJobStarted(startedJob));
return;
}
setJob(startedJob);
setStatus('polling');
schedulePoll(startedJob);
} catch (err) {
setError(err.response?.data?.error || err.message || 'Something went wrong. Please try again.');
setStatus('error'); setStatus('error');
} }
} }
const stageMeta = getBusinessOnboardingStageMeta(job?.stage || status);
const progress = getBusinessOnboardingProgress(job || {});
const isFailed = status === 'error' && !!job;
const hasStarted = !!job;
const showSuccessScreen = status === 'success' && showCompletionScreen;
const errorMessage = getBusinessOnboardingError(job) || error;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md "> <div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md">
{showSuccessScreen && (
{status === 'success' && (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 mb-3">Business created</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 mb-3">Business created</p>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Storefront captured successfully</h2> <h2 className="text-xl font-semibold text-gray-900 mb-2">{job?.business?.brandName || 'Storefront captured successfully'}</h2>
<p className="text-sm text-gray-500 mb-5 leading-relaxed"> <p className="text-sm text-gray-500 mb-5 leading-relaxed">
The business has been created and the scraped storefront details are ready for review. The onboarding job finished successfully and the business is ready for review.
</p> </p>
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 mb-6"> <div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-400 mb-1">Website URL</p> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-400 mb-1">Website URL</p>
@ -49,17 +134,37 @@ export default function RegisterBusinessModal({ onClose, onSuccess }) {
onClick={onClose} onClick={onClose}
className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition" className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
> >
Next Continue
</button> </button>
</div> </div>
)} )}
{(status === 'idle' || status === 'loading' || status === 'error') && ( {isFailed && (
<div className="space-y-4">
<div className="mb-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 mb-2">Onboarding failed</p>
<h2 className="text-xl font-semibold text-gray-900 mb-2">We could not finish creating this business</h2>
<p className="text-sm text-gray-500 leading-relaxed">
{errorMessage || 'The onboarding job stopped before the business could be created.'}
</p>
</div>
<button
onClick={onClose}
className="w-full py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition"
>
Close
</button>
</div>
)}
{!showSuccessScreen && !isFailed && (
<>
{!hasStarted && (
<> <>
<div className="mb-6"> <div className="mb-6">
<h2 className="text-xl font-bold text-gray-800 mb-2 tracking-tight">Add a Business</h2> <h2 className="text-xl font-bold text-gray-800 mb-2 tracking-tight">Add a Business</h2>
<p className="text-gray-500 text-sm leading-relaxed"> <p className="text-gray-500 text-sm leading-relaxed">
Enter the storefront website URL and we&apos;ll scrape it to detect the brand and set up your business. Enter the storefront website URL and we&apos;ll scrape the homepage, about page, and representative product pages to detect the brand and set up your business.
</p> </p>
</div> </div>
@ -71,45 +176,99 @@ export default function RegisterBusinessModal({ onClose, onSuccess }) {
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
placeholder="https://yourstore.com" placeholder="https://yourstore.com"
disabled={status === 'loading'} disabled={status === 'starting'}
className="w-full px-4 py-2 rounded-lg bg-white border border-gray-300 text-gray-800 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition disabled:opacity-50 text-sm " className="w-full px-4 py-2 rounded-lg bg-white border border-gray-300 text-gray-800 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition disabled:opacity-50 text-sm"
required required
/> />
</div> </div>
{status === 'error' && ( {status === 'error' && !job && (
<p className="text-sm text-red-600 font-medium bg-white border border-gray-200 rounded-lg px-3 py-2">{error}</p> <p className="text-sm text-red-600 font-medium bg-white border border-gray-200 rounded-lg px-3 py-2">
{error}
</p>
)} )}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={status === 'loading'} disabled={status === 'starting'}
className="flex-[0.8] py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" className="flex-[0.8] py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={status === 'loading' || !url.trim()} disabled={status === 'starting' || !url.trim()}
className="flex-[1.2] py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition disabled:opacity-50 flex items-center justify-center gap-2" className="flex-[1.2] py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{status === 'loading' ? ( {status === 'starting' ? (
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Analysing</> <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Starting crawl</>
) : 'Add Business'} ) : 'Start setup'}
</button> </button>
</div> </div>
{status === 'loading' && (
<p className="text-xs text-gray-500 font-medium text-center pt-2">
Fetching the website context and extracting brand details. This may take 2030 seconds.
</p>
)}
</form> </form>
</> </>
)} )}
{hasStarted && !showSuccessScreen && (
<div className="space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 mb-2">
{status === 'error' ? 'Onboarding failed' : 'Onboarding in progress'}
</p>
<h2 className="text-xl font-semibold text-gray-900 mb-2">{stageMeta.label}</h2>
<p className="text-sm text-gray-500 leading-relaxed">
{status === 'error'
? errorMessage || 'The onboarding job failed.'
: stageMeta.note}
</p>
</div>
<div className="space-y-2">
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className={`h-full rounded-full transition-all ${status === 'error' ? 'bg-red-500' : 'bg-primary-blue'}`}
style={{ width: `${stageMeta.percent}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs font-medium text-gray-500">
<span>Status: {job?.status || status}</span>
{progress.pagesDiscovered > 0 ? (
<span>{progress.pagesProcessed} / {progress.pagesDiscovered} pages</span>
) : (
<span>Preparing crawl</span>
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Pages</p>
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.pagesProcessed}</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Links</p>
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.linkCount}</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Images</p>
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.imageCount}</p>
</div>
</div>
{status === 'error' && (
<button
onClick={onClose}
className="w-full py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition"
>
Close
</button>
)}
</div>
)}
</>
)}
</div> </div>
</div> </div>
); );

View File

@ -59,7 +59,7 @@ function StageMarker({ done, active, enabled }) {
return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />; return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />;
} }
export default function Sidebar() { export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) {
const { const {
activeBusiness, activeBusiness,
activeBusinessId, activeBusinessId,
@ -132,13 +132,43 @@ export default function Sidebar() {
<span>Switch Business</span> <span>Switch Business</span>
</button> </button>
{activeBusiness && ( {activeBusiness && (
<div className="mt-4 flex items-center gap-3"> <div className="mt-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-md bg-primary-blue flex items-center justify-center text-sm font-bold text-white shrink-0 "> <div className="w-8 h-8 rounded-md bg-primary-blue flex items-center justify-center text-sm font-bold text-white shrink-0 ">
{activeBusiness.brandName?.[0]?.toUpperCase() || 'B'} {activeBusiness.brandName?.[0]?.toUpperCase() || 'B'}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-gray-800 truncate">{activeBusiness.brandName}</p> <p className="text-sm font-semibold text-gray-800 truncate">{activeBusiness.brandName}</p>
<p className="text-xs text-gray-400 truncate font-medium">{activeBusiness.domain}</p> <p className="text-xs text-gray-400 truncate font-medium">{activeBusiness.domain}</p>
{activeBusinessId && (
<>
<div className="relative mt-2 inline-flex items-center">
<button
onClick={onOpenReview}
disabled={reviewLoading}
className="group inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-500 transition hover:border-gray-300 hover:bg-gray-50 hover:text-primary-blue disabled:cursor-wait disabled:opacity-60"
title="View brand details"
aria-label="View brand details"
>
{reviewLoading ? (
<span className="h-4 w-4 rounded-full border-2 border-gray-200 border-t-primary-blue animate-spin" />
) : (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M1.458 12C2.732 7.943 6.523 5 12 5c5.477 0 9.268 2.943 10.542 7-1.274 4.057-5.065 7-10.542 7-5.477 0-9.268-2.943-10.542-7z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15a3 3 0 100-6 3 3 0 000 6z" />
</svg>
)}
<span className="pointer-events-none absolute left-full top-1/2 ml-2 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm group-hover:block">
View brand details
</span>
</button>
</div>
{reviewError && (
<p className="mt-2 text-xs text-red-600">{reviewError}</p>
)}
</>
)}
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useBrand } from '../context/BrandContext'; import { useBrand } from '../context/BrandContext';
import BusinessReviewModal from '../components/BusinessReviewModal';
import RegisterBusinessModal from '../components/RegisterBusinessModal'; import RegisterBusinessModal from '../components/RegisterBusinessModal';
import apiClient from '../api/client'; import apiClient from '../api/client';
@ -45,6 +46,7 @@ function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) {
export default function Brand() { export default function Brand() {
const { brand, loading, refetch } = useBrand(); const { brand, loading, refetch } = useBrand();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showReviewModal, setShowReviewModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState(''); const [deleteError, setDeleteError] = useState('');
@ -122,59 +124,7 @@ export default function Brand() {
</button> </button>
</div> </div>
{/* Taglines */}
{brand.taglines?.length > 0 && (
<div className="mt-6 pt-6 border-t border-gray-100">
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-3">Taglines</p>
<div className="space-y-1.5">
{brand.taglines.map((t, i) => (
<p key={i} className="text-gray-700 text-sm">"{t}"</p>
))}
</div> </div>
</div>
)}
{/* Colors */}
{brand.colors?.length > 0 && (
<div className="mt-6">
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-3">Brand Colors</p>
<div className="flex gap-2 flex-wrap">
{brand.colors.map((c, i) => (
<div key={i} className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-md border border-gray-200"
style={{ backgroundColor: c }}
title={c}
/>
<span className="text-xs text-gray-500 font-mono">{c}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Brand images */}
{brand.relevantImagePaths?.length > 0 && (
<div className="rounded-lg bg-white border border-gray-200 p-5 mb-6">
<p className="text-xs text-gray-400 font-semibold uppercase tracking-wider mb-4">Brand Images</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{brand.relevantImagePaths.map((url, i) => (
<div key={i} className="group relative rounded-lg overflow-hidden border border-gray-200 aspect-video bg-white">
<img
src={url}
alt={`brand image ${i + 1}`}
className="w-full h-full object-cover"
onError={e => { e.target.style.opacity = '0.3'; }}
/>
<div className="absolute inset-0 bg-gray-900/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-2">
<p className="text-xs text-white break-all leading-tight line-clamp-3 font-medium">{url}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Navigation cards */} {/* Navigation cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
@ -196,6 +146,13 @@ export default function Brand() {
)} )}
</div> </div>
<button
onClick={() => setShowReviewModal(true)}
className="fixed bottom-6 right-6 z-30 rounded-full bg-primary-blue px-5 py-3 text-sm font-semibold text-white shadow-lg transition hover:bg-primary-dark"
>
View Brand Review
</button>
{showDeleteConfirm && ( {showDeleteConfirm && (
<DeleteConfirmModal <DeleteConfirmModal
brandName={brand.brandName} brandName={brand.brandName}
@ -204,6 +161,18 @@ export default function Brand() {
deleting={deleting} deleting={deleting}
/> />
)} )}
{showReviewModal && (
<BusinessReviewModal
business={brand}
eyebrow="Business review"
helperText={brand?.domain
? `Review the captured brand context for ${brand.domain}.`
: 'Review the captured brand context for this business.'}
closeLabel="Close"
onClose={() => setShowReviewModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@ -3,7 +3,16 @@ import { useNavigate } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
import RegisterBusinessModal from '../components/RegisterBusinessModal'; import RegisterBusinessModal from '../components/RegisterBusinessModal';
import BusinessReviewModal from '../components/BusinessReviewModal';
import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels'; import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
import {
fetchBusinessOnboardingJob,
getBusinessOnboardingError,
getBusinessOnboardingProgress,
getBusinessOnboardingStageMeta,
shouldRetryMissingBusinessOnboardingJob,
startBusinessOnboardingJob,
} from '../utils/businessOnboarding';
import { import {
getApplicationId, getApplicationId,
getBusinessDomain, getBusinessDomain,
@ -16,106 +25,6 @@ function normalizeText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function normalizeUniqueStrings(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((entry) => normalizeText(entry))
.filter((entry) => {
if (!entry || seen.has(entry)) return false;
seen.add(entry);
return true;
});
}
function extractCdnUrls(business) {
return normalizeUniqueStrings(business?.relevantImagePaths);
}
function normalizeScrapeLinks(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((entry) => {
if (typeof entry === 'string') {
const href = normalizeText(entry);
return href ? { href, label: href } : null;
}
if (!entry || typeof entry !== 'object') return null;
const href = normalizeText(entry.href || entry.url || entry.link);
if (!href) return null;
const label = normalizeText(entry.text || entry.title || entry.label || href);
return { href, label };
})
.filter((entry) => {
if (!entry || seen.has(entry.href)) return false;
seen.add(entry.href);
return true;
});
}
function formatPrettyJson(value) {
if (value == null) return '';
if (typeof value === 'string') {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function CdnGallery({ urls, compact = false, showLabels = true, clickable = true }) {
if (!urls.length) return null;
return (
<div className={`grid gap-3 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'}`}>
{urls.map((url, index) => {
const Wrapper = clickable ? 'a' : 'div';
const wrapperProps = clickable
? { href: url, target: '_blank', rel: 'noreferrer' }
: {};
return (
<Wrapper
key={`${url}-${index}`}
{...wrapperProps}
className={`group overflow-hidden rounded-xl border border-gray-200 bg-white transition ${clickable ? 'hover:border-primary-blue' : ''}`}
>
<div className={`bg-gray-50 ${compact ? 'aspect-[4/3]' : 'aspect-[5/4]'}`}>
<img
src={url}
alt={`Storefront image ${index + 1}`}
className="h-full w-full object-cover"
onError={(event) => {
event.currentTarget.style.opacity = '0.35';
}}
/>
</div>
{showLabels && (
<div className="border-t border-gray-100 px-3 py-2">
<p className="text-xs text-gray-500 break-all leading-relaxed line-clamp-3">{url}</p>
</div>
)}
</Wrapper>
);
})}
</div>
);
}
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) { function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
@ -148,143 +57,94 @@ function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
); );
} }
function BusinessCreatedModal({ business, onClose }) { function BusinessOnboardingModal({ job, onClose }) {
const name = getBusinessName(business); const status = normalizeText(job?.status);
const domain = getBusinessDomain(business); const stageMeta = getBusinessOnboardingStageMeta(job?.stage || status);
const tagline = getBusinessTagline(business); const progress = getBusinessOnboardingProgress(job);
const image = getBusinessImage(business); const isFailed = status === 'failed';
const cdnUrls = extractCdnUrls(business?.scrapeArtifacts?.cdnUrls?.length ? { relevantImagePaths: business.scrapeArtifacts.cdnUrls } : business); const isCompleted = status === 'completed';
const links = normalizeScrapeLinks(business?.scrapeArtifacts?.links); const discoveredPages = progress.pagesDiscovered;
const prettyJson = useMemo(() => formatPrettyJson(business?.scrapeArtifacts?.json), [business]); const processedPages = progress.pagesProcessed;
const progressWidth = stageMeta.percent;
useEffect(() => { const errorMessage = getBusinessOnboardingError(job);
const previousBodyOverflow = document.body.style.overflow;
const previousBodyOverscroll = document.body.style.overscrollBehavior;
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousHtmlOverscroll = document.documentElement.style.overscrollBehavior;
document.body.style.overflow = 'hidden';
document.body.style.overscrollBehavior = 'none';
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overscrollBehavior = 'none';
return () => {
document.body.style.overflow = previousBodyOverflow;
document.body.style.overscrollBehavior = previousBodyOverscroll;
document.documentElement.style.overflow = previousHtmlOverflow;
document.documentElement.style.overscrollBehavior = previousHtmlOverscroll;
};
}, []);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div <div className="w-full max-w-lg rounded-2xl border border-gray-200 bg-white shadow-xl">
className="flex h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl" <div className="border-b border-gray-200 px-6 py-5">
style={{ width: '920px', maxWidth: 'calc(100vw - 2rem)', height: 'min(78vh, 760px)' }} <p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">
> {isFailed ? 'Onboarding failed' : isCompleted ? 'Business ready' : 'Setting up business'}
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5"> </p>
<div> <h2 className="mt-2 text-2xl font-semibold text-gray-900">{stageMeta.label}</h2>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Business created</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{name}</h2>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{domain {isFailed
? `Scrape completed for ${domain}. Review the captured assets below before moving on.` ? 'The onboarding job could not be completed. You can close this dialog and try again.'
: 'Scrape completed. Review the captured assets below before moving on.'} : isCompleted
? 'The storefront crawl and brand analysis finished successfully.'
: stageMeta.note}
</p> </p>
</div> </div>
<button
onClick={onClose}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-50 hover:text-gray-900"
>
Close
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-5"> <div className="space-y-5 px-6 py-5">
<div className="space-y-5 pb-2"> {!isFailed && (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4"> <>
<div className="flex items-start gap-4"> <div className="space-y-2">
<div className="w-16 h-16 rounded-xl overflow-hidden bg-white border border-gray-200 shrink-0 flex items-center justify-center"> <div className="h-2 overflow-hidden rounded-full bg-gray-100">
{image ? ( <div
<img src={image} alt={name} className="w-full h-full object-cover" /> className={`h-full rounded-full transition-all ${isCompleted ? 'bg-green-500' : 'bg-primary-blue'}`}
style={{ width: `${progressWidth}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs font-medium text-gray-500">
<span>Status: {status || 'pending'}</span>
{discoveredPages > 0 ? (
<span>{processedPages} / {discoveredPages} pages</span>
) : ( ) : (
<span className="text-xl font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'B'}</span> <span>Preparing crawl</span>
)}
</div>
<div className="min-w-0">
<p className="text-lg font-semibold tracking-tight text-gray-900">{name}</p>
{domain && <p className="mt-1 text-sm font-medium text-gray-500 break-all">{domain}</p>}
{tagline && <p className="mt-2 text-sm leading-relaxed text-gray-700">{tagline}</p>}
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-gray-600 border border-gray-200">
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
</span>
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-gray-600 border border-gray-200">
{links.length} link{links.length === 1 ? '' : 's'}
</span>
</div>
</div>
</div>
</div>
{cdnUrls.length > 0 && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Images</p>
<p className="mt-1 text-sm text-gray-500">Captured storefront images are available below.</p>
</div>
<CdnGallery urls={cdnUrls} compact showLabels={false} />
</section>
)}
{prettyJson && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Captured Data</p>
<p className="mt-1 text-sm text-gray-500">Raw storefront data captured during onboarding.</p>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
{prettyJson}
</pre>
</div>
</section>
)}
{links.length > 0 && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Links</p>
<p className="mt-1 text-sm text-gray-500">Every discovered storefront link is available below.</p>
</div>
<div className="rounded-xl border border-gray-200">
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
{links.map((link, index) => (
<a
key={`${link.href}-${index}`}
href={link.href}
target="_blank"
rel="noreferrer"
className="block px-4 py-3 transition hover:bg-gray-50"
>
<p className="text-sm font-medium text-gray-800 break-all">{link.label}</p>
<p className="mt-1 text-xs text-primary-blue break-all">{link.href}</p>
</a>
))}
</div>
</div>
</section>
)} )}
</div> </div>
</div> </div>
<div className="shrink-0 border-t border-gray-200 px-6 py-4"> <div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Pages</p>
<p className="mt-2 text-lg font-semibold text-gray-900">{processedPages}</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Links</p>
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.linkCount}</p>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-400">Images</p>
<p className="mt-2 text-lg font-semibold text-gray-900">{progress.imageCount}</p>
</div>
</div>
</>
)}
{isFailed && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{errorMessage || 'Business onboarding failed.'}
</div>
)}
</div>
<div className="border-t border-gray-200 px-6 py-4">
{isFailed ? (
<button <button
onClick={onClose} onClick={onClose}
className="w-full rounded-lg bg-primary-blue py-2 text-sm font-medium text-white transition hover:bg-primary-dark" className="w-full rounded-lg bg-primary-blue py-2 text-sm font-medium text-white transition hover:bg-primary-dark"
> >
Continue Close
</button> </button>
) : (
<button
disabled
className="w-full rounded-lg bg-gray-100 py-2 text-sm font-medium text-gray-500"
>
Working
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -314,10 +174,12 @@ function UnifiedBusinessCard({
item, item,
selectingBusinessId, selectingBusinessId,
creatingSalesChannelId, creatingSalesChannelId,
reviewLoadingBusinessId,
onSelect, onSelect,
onImport, onImport,
onDelete, onDelete,
onFallback, onFallback,
onReview,
}) { }) {
const entity = item.business || item.channel; const entity = item.business || item.channel;
const businessId = item.business?.businessId || ''; const businessId = item.business?.businessId || '';
@ -327,9 +189,9 @@ function UnifiedBusinessCard({
const domain = getBusinessDomain(entity); const domain = getBusinessDomain(entity);
const tagline = getBusinessTagline(entity); const tagline = getBusinessTagline(entity);
const isScraped = item.status === 'scraped'; const isScraped = item.status === 'scraped';
const cdnUrls = extractCdnUrls(item.business);
const isOpening = isScraped && selectingBusinessId === businessId; const isOpening = isScraped && selectingBusinessId === businessId;
const isImporting = !isScraped && creatingSalesChannelId === channelId; const isImporting = !isScraped && creatingSalesChannelId === channelId;
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl); const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
const canOpenBusiness = isScraped && item.business && !isOpening; const canOpenBusiness = isScraped && item.business && !isOpening;
@ -393,17 +255,6 @@ function UnifiedBusinessCard({
</p> </p>
)} )}
{isScraped && cdnUrls.length > 0 && (
<div className="mt-4 border-t border-gray-100 pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Images</p>
<span className="text-xs font-medium text-gray-400">
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
</span>
</div>
<CdnGallery urls={cdnUrls.slice(0, 6)} compact showLabels={false} clickable={false} />
</div>
)}
</div> </div>
<div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3"> <div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3">
@ -418,6 +269,16 @@ function UnifiedBusinessCard({
> >
Delete Delete
</button> </button>
<button
onClick={(event) => {
event.stopPropagation();
onReview(item.business);
}}
disabled={isLoadingReview}
className="rounded-md bg-primary-blue px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary-blue/30 disabled:opacity-60"
>
{isLoadingReview ? 'Loading review…' : 'View Brand Review'}
</button>
</> </>
) : ( ) : (
<> <>
@ -432,10 +293,10 @@ function UnifiedBusinessCard({
disabled={isImporting} disabled={isImporting}
className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60" className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
> >
{isImporting ? 'Scraping…' : hasWebsiteUrl ? 'Scrape →' : 'Use Fallback URL →'} {isImporting ? 'Onboarding…' : hasWebsiteUrl ? 'Start onboarding →' : 'Use fallback URL →'}
</button> </button>
<span className="text-xs text-gray-500 font-medium"> <span className="text-xs text-gray-500 font-medium">
{hasWebsiteUrl ? 'Ready to scrape' : 'Needs manual URL'} {hasWebsiteUrl ? 'Ready to onboard' : 'Needs manual URL'}
</span> </span>
</> </>
)} )}
@ -455,10 +316,15 @@ export default function Businesses() {
const [selectingBusinessId, setSelectingBusinessId] = useState(''); const [selectingBusinessId, setSelectingBusinessId] = useState('');
const [creatingSalesChannelId, setCreatingSalesChannelId] = useState(''); const [creatingSalesChannelId, setCreatingSalesChannelId] = useState('');
const [createdBusiness, setCreatedBusiness] = useState(null); const [createdBusiness, setCreatedBusiness] = useState(null);
const [onboardingJob, setOnboardingJob] = useState(null);
const [showOnboardingModal, setShowOnboardingModal] = useState(false);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [reviewBusiness, setReviewBusiness] = useState(null);
const [reviewLoadingBusinessId, setReviewLoadingBusinessId] = useState('');
const onboardingJobCreatedAt = onboardingJob?.createdAt;
const showUnifiedSalesChannelView = salesChannelsStatus === 'success'; const showUnifiedSalesChannelView = salesChannelsStatus === 'success';
@ -557,8 +423,9 @@ export default function Businesses() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
async function handleBusinessCreated(created) { const handleBusinessCreated = useCallback(async (created) => {
setShowModal(false); setShowModal(false);
setShowOnboardingModal(false);
setCreatedBusiness(created); setCreatedBusiness(created);
try { try {
@ -567,7 +434,73 @@ export default function Businesses() {
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.'); setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
} }
}, [loadBusinesses, loadSalesChannels]);
const handleBusinessJobStarted = useCallback(async (job) => {
setError('');
setShowModal(false);
setCreatedBusiness(null);
setOnboardingJob(job);
setShowOnboardingModal(true);
}, []);
useEffect(() => {
if (!onboardingJob?.jobId) return undefined;
if (onboardingJob.status === 'completed') return undefined;
let cancelled = false;
let timeoutId = null;
let transientNotFoundMisses = 0;
async function pollJob() {
try {
const nextJob = await fetchBusinessOnboardingJob(onboardingJob.jobId);
if (cancelled) return;
transientNotFoundMisses = 0;
setOnboardingJob(nextJob);
if (nextJob.status === 'completed' && nextJob.business) {
await handleBusinessCreated(nextJob.business);
if (!cancelled) {
setOnboardingJob(null);
setShowOnboardingModal(false);
} }
return;
}
if (nextJob.status === 'failed') {
setShowOnboardingModal(true);
return;
}
timeoutId = window.setTimeout(pollJob, 2200);
} catch (err) {
if (cancelled) return;
if (shouldRetryMissingBusinessOnboardingJob({ createdAt: onboardingJobCreatedAt }, err, transientNotFoundMisses)) {
transientNotFoundMisses += 1;
timeoutId = window.setTimeout(pollJob, 1800);
return;
}
setOnboardingJob((current) => ({
...(current || {}),
status: 'failed',
stage: 'failed',
error: { message: err.response?.data?.error || 'Failed to fetch onboarding progress.' },
}));
setShowOnboardingModal(true);
}
}
timeoutId = window.setTimeout(pollJob, 2500);
return () => {
cancelled = true;
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [handleBusinessCreated, onboardingJob?.jobId, onboardingJob?.status, onboardingJobCreatedAt]);
async function handleSelect(biz) { async function handleSelect(biz) {
setSelectingBusinessId(biz.businessId); setSelectingBusinessId(biz.businessId);
@ -595,11 +528,11 @@ export default function Businesses() {
setError(''); setError('');
try { try {
const res = await apiClient.post('/api/businesses', { const job = await startBusinessOnboardingJob({
applicationId, applicationId,
websiteUrl: channel.websiteUrl, websiteUrl: channel.websiteUrl,
}); });
await handleBusinessCreated(res.data); await handleBusinessJobStarted(job);
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to add business from sales channel'); setError(err.response?.data?.error || 'Failed to add business from sales channel');
} finally { } finally {
@ -621,6 +554,27 @@ export default function Businesses() {
} }
} }
async function handleOpenReview(business) {
if (!business?.businessId || reviewLoadingBusinessId) return;
setReviewLoadingBusinessId(business.businessId);
setError('');
try {
if (business?.scrapeArtifacts?.json) {
setReviewBusiness(business);
return;
}
const response = await apiClient.get(`/api/businesses/${business.businessId}`);
setReviewBusiness(response.data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load brand review.');
} finally {
setReviewLoadingBusinessId('');
}
}
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <div className="min-h-screen flex items-center justify-center bg-white">
@ -639,8 +593,8 @@ export default function Businesses() {
</h1> </h1>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{showUnifiedSalesChannelView {showUnifiedSalesChannelView
? 'View every connected sales channel in one place and scrape the ones that are not onboarded yet.' ? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
: 'Add a storefront URL and well scrape it to set up your business.'} : 'Add a storefront URL and well scrape the homepage, about page, and representative product pages to set up your business.'}
</p> </p>
</div> </div>
{!showUnifiedSalesChannelView && ( {!showUnifiedSalesChannelView && (
@ -687,10 +641,12 @@ export default function Businesses() {
item={item} item={item}
selectingBusinessId={selectingBusinessId} selectingBusinessId={selectingBusinessId}
creatingSalesChannelId={creatingSalesChannelId} creatingSalesChannelId={creatingSalesChannelId}
reviewLoadingBusinessId={reviewLoadingBusinessId}
onSelect={handleSelect} onSelect={handleSelect}
onImport={handleCreateFromSalesChannel} onImport={handleCreateFromSalesChannel}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
onFallback={() => setShowModal(true)} onFallback={() => setShowModal(true)}
onReview={handleOpenReview}
/> />
))} ))}
</div> </div>
@ -730,10 +686,12 @@ export default function Businesses() {
item={{ key: `fallback:${biz.businessId}`, status: 'scraped', business: biz, channel: null }} item={{ key: `fallback:${biz.businessId}`, status: 'scraped', business: biz, channel: null }}
selectingBusinessId={selectingBusinessId} selectingBusinessId={selectingBusinessId}
creatingSalesChannelId={creatingSalesChannelId} creatingSalesChannelId={creatingSalesChannelId}
reviewLoadingBusinessId={reviewLoadingBusinessId}
onSelect={handleSelect} onSelect={handleSelect}
onImport={handleCreateFromSalesChannel} onImport={handleCreateFromSalesChannel}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
onFallback={() => setShowModal(true)} onFallback={() => setShowModal(true)}
onReview={handleOpenReview}
/> />
))} ))}
</div> </div>
@ -750,10 +708,32 @@ export default function Businesses() {
{showModal && ( {showModal && (
<RegisterBusinessModal <RegisterBusinessModal
onClose={() => { setShowModal(false); load(); }} onClose={() => { setShowModal(false); load(); }}
onSuccess={handleBusinessCreated} onJobStarted={handleBusinessJobStarted}
/>
)}
{onboardingJob && showOnboardingModal && (
<BusinessOnboardingModal
job={onboardingJob}
onClose={() => setShowOnboardingModal(false)}
/>
)}
{createdBusiness && (
<BusinessReviewModal
business={createdBusiness}
eyebrow="Business created"
helperText={createdBusiness?.domain
? `Onboarding completed for ${createdBusiness.domain}. Review the captured brand context before moving on.`
: 'Onboarding completed. Review the captured brand context before moving on.'}
closeLabel="Continue"
onClose={() => setCreatedBusiness(null)}
/>
)}
{reviewBusiness && (
<BusinessReviewModal
business={reviewBusiness}
onClose={() => setReviewBusiness(null)}
/> />
)} )}
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
{deleteTarget && ( {deleteTarget && (
<DeleteConfirmModal <DeleteConfirmModal
businessName={getBusinessName(deleteTarget)} businessName={getBusinessName(deleteTarget)}

View File

@ -0,0 +1,98 @@
import apiClient from '../api/client';
const STAGE_SEQUENCE = [
{ key: 'queued', label: 'Preparing scrape', note: 'Setting up the onboarding job.' },
{ key: 'crawling', label: 'Scraping brand pages', note: 'Capturing the homepage, about page, and representative product pages.' },
{ key: 'summarizing', label: 'Building site summary', note: 'Condensing the scraped pages into a compact brand summary.' },
{ key: 'parsing_brand', label: 'Extracting brand context', note: 'Inferring tone, taglines, and visual signals.' },
{ key: 'finalizing_business', label: 'Finalizing business', note: 'Saving the captured data and storefront assets.' },
{ key: 'completed', label: 'Completed', note: 'The onboarding job finished successfully.' },
{ key: 'failed', label: 'Failed', note: 'The onboarding job could not be completed.' },
];
const JOB_LOOKUP_RETRY_LIMIT = 4;
const RETRYABLE_JOB_NOT_FOUND_WINDOW_MS = 20_000;
const MAX_RETRYABLE_JOB_NOT_FOUND_MISSES = 4;
export async function startBusinessOnboardingJob(payload) {
const response = await apiClient.post('/api/businesses', payload);
if (response.status !== 202 || !response.data?.jobId) {
throw new Error('Unexpected onboarding response');
}
return response.data;
}
export async function fetchBusinessOnboardingJob(jobId) {
const response = await apiClient.get(`/api/businesses/jobs/${encodeURIComponent(jobId)}`);
return response.data;
}
export function getBusinessOnboardingStageMeta(stage) {
const normalizedStage = String(stage || '').trim().toLowerCase();
const found = STAGE_SEQUENCE.find((entry) => entry.key === normalizedStage) || STAGE_SEQUENCE[0];
const index = STAGE_SEQUENCE.findIndex((entry) => entry.key === found.key);
const percent = normalizedStage === 'completed'
? 100
: normalizedStage === 'failed'
? 100
: Math.max(8, Math.min(92, index <= 0 ? 12 : 12 + (index * 20)));
return {
stage: found.key,
label: found.label,
note: found.note,
index,
percent,
totalStages: STAGE_SEQUENCE.length - 2,
};
}
export function getBusinessOnboardingProgress(job = {}) {
const progress = job?.progress && typeof job.progress === 'object' ? job.progress : {};
return {
pagesProcessed: Number(progress.pagesProcessed || 0),
pagesDiscovered: Number(progress.pagesDiscovered || 0),
creditsUsed: Number(progress.creditsUsed || 0),
representativePages: Number(progress.representativePages || 0),
imageCount: Number(progress.imageCount || 0),
linkCount: Number(progress.linkCount || 0),
};
}
export function getBusinessOnboardingError(job = {}) {
const error = job?.error;
if (!error) return '';
if (typeof error === 'string') return error;
if (typeof error === 'object') {
return String(error.message || error.error || error.details || '').trim();
}
return String(error).trim();
}
export function isRetryableOnboardingJobLookupError(error) {
if (error?.response?.status !== 404) return false;
const message = String(error?.response?.data?.error || error?.message || '').trim();
return /onboarding job not found/i.test(message);
}
export function shouldRetryOnboardingJobLookup(error, missCount = 0) {
return isRetryableOnboardingJobLookupError(error) && missCount < JOB_LOOKUP_RETRY_LIMIT;
}
export function getOnboardingJobRetryDelay(missCount = 0) {
return Math.min(4500, 1500 + (Math.max(0, missCount) * 700));
}
export function shouldRetryMissingBusinessOnboardingJob(job = {}, error, missCount = 0) {
if (error?.response?.status !== 404) return false;
if (missCount >= MAX_RETRYABLE_JOB_NOT_FOUND_MISSES) return false;
const createdAtMs = Date.parse(String(job?.createdAt || '').trim());
if (!Number.isFinite(createdAtMs)) return false;
return Date.now() - createdAtMs <= RETRYABLE_JOB_NOT_FOUND_WINDOW_MS;
}

View File

@ -1,9 +1,10 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { scrape } = require('../services/firecrawl'); const { buildBrandContextPlan, collectBrandContextPages } = require('../services/firecrawl');
const { parseBrandContext, generateTemplates, processCurl, validateCurlFields } = require('../services/openai2'); const { parseBrandContext, generateTemplates, processCurl, validateCurlFields } = require('../services/openai2');
const { sendViaWorkflow } = require('../services/workflowSender'); const { sendViaWorkflow } = require('../services/workflowSender');
const { buildCrawlSummary } = require('../services/crawlSummary');
const { const {
uploadJSON, uploadJSON,
fetchJSON, fetchJSON,
@ -235,6 +236,249 @@ function mergeBusinessSummary(baseBusiness = {}, context = null) {
}; };
} }
function onboardingJobsRoot(companyId) {
return `${companyId}/jobs`;
}
function buildScrapeArtifacts(crawlSummary, imagePaths = []) {
return {
cdnUrls: normalizeUrlList(imagePaths),
links: Array.isArray(crawlSummary?.links) ? crawlSummary.links : [],
json: crawlSummary && typeof crawlSummary === 'object' ? crawlSummary : {},
};
}
function extractAboutSummary(crawlSummary = {}) {
return normalizeText(
crawlSummary?.aboutPage?.excerpt
|| crawlSummary?.aboutPage?.description
|| crawlSummary?.homepage?.description
|| crawlSummary?.homepage?.excerpt
|| ''
);
}
function buildJobResponse(job) {
return {
jobId: normalizeText(job?.jobId),
status: normalizeText(job?.status),
stage: normalizeText(job?.stage),
companyId: normalizeScopeId(job?.companyId),
applicationId: normalizeScopeId(job?.applicationId),
websiteUrl: normalizeWebsiteUrl(job?.websiteUrl),
progress: job?.progress && typeof job.progress === 'object' ? job.progress : {},
business: job?.business && typeof job.business === 'object' ? job.business : null,
error: job?.error && typeof job.error === 'object' ? job.error : null,
createdAt: normalizeText(job?.createdAt),
updatedAt: normalizeText(job?.updatedAt),
};
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function loadOnboardingJob(companyId, jobId) {
return fetchJSON(onboardingJobsRoot(companyId), jobId);
}
async function loadOnboardingJobWithRetry(companyId, jobId, options = {}) {
const attempts = Number.isFinite(options.attempts) ? options.attempts : 6;
const delayMs = Number.isFinite(options.delayMs) ? options.delayMs : 350;
for (let attempt = 0; attempt < attempts; attempt += 1) {
const job = await loadOnboardingJob(companyId, jobId);
if (job) return job;
if (attempt < attempts - 1) {
await wait(delayMs);
}
}
return null;
}
async function saveOnboardingJob(job) {
const normalizedJob = {
...job,
updatedAt: new Date().toISOString(),
};
await uploadJSON(onboardingJobsRoot(normalizedJob.companyId), normalizedJob.jobId, normalizedJob);
return normalizedJob;
}
async function finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext) {
const merchantId = job.companyId;
const applicationId = normalizeScopeId(job.applicationId);
const websiteUrl = normalizeWebsiteUrl(job.websiteUrl);
if (applicationId) {
const existingBusiness = await findBusinessByApplicationId(merchantId, applicationId);
if (existingBusiness) {
const existingContext = await fetchJSON(businessRoot(merchantId, existingBusiness.businessId), 'context').catch(() => null);
const mergedBusiness = existingContext ? { ...existingContext } : mergeBusinessSummary(existingBusiness);
return {
business: {
...mergedBusiness,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, mergedBusiness.relevantImagePaths),
},
reusedExistingBusiness: true,
};
}
}
const businesses = await getIndex(merchantId);
const businessId = uuidv4();
const bizRoot = businessRoot(merchantId, businessId);
const imagesFolder = `${bizRoot}/images`;
const imagePaths = [];
const imageCandidates = normalizeUrlList(brandContext?.relevantImageUrls);
for (let i = 0; i < Math.min(imageCandidates.length, 6); i += 1) {
const uploaded = await uploadImageFromUrl(imageCandidates[i], imagesFolder, `image_${i + 1}`);
if (uploaded) imagePaths.push(uploaded);
}
let domain = normalizeText(crawlSummary?.domain);
if (!domain) {
try {
domain = new URL(websiteUrl).hostname;
} catch {
domain = '';
}
}
const now = new Date().toISOString();
const contextJson = {
businessId,
merchantId,
companyId: merchantId,
applicationId,
domain,
brandName: brandContext.brandName || 'Unknown Brand',
tone: brandContext.tone || 'professional',
taglines: Array.isArray(brandContext.taglines) ? brandContext.taglines : [],
colors: Array.isArray(brandContext.colors) ? brandContext.colors : [],
relevantImagePaths: imagePaths,
aboutSummary: normalizeText(brandContext.aboutSummary) || extractAboutSummary(crawlSummary),
websiteUrl,
crawlStats: crawlSummary?.siteStats || {},
createdAt: now,
updatedAt: now,
};
await uploadJSON(bizRoot, 'context', contextJson);
await uploadJSON(bizRoot, 'crawl_summary', crawlSummary);
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
const previewSummary = getBusinessPreviewSummary(contextJson);
businesses.push({
businessId,
companyId: merchantId,
applicationId,
brandName: contextJson.brandName,
domain: contextJson.domain,
previewTagline: previewSummary.previewTagline,
previewImagePath: previewSummary.previewImagePath,
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
createdAt: contextJson.createdAt,
updatedAt: contextJson.updatedAt,
});
await saveIndex(merchantId, businesses);
return {
business: {
...contextJson,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, imagePaths),
},
reusedExistingBusiness: false,
};
}
async function advanceOnboardingJob(job) {
if (!job || typeof job !== 'object') {
throw createHttpError(404, 'Onboarding job not found');
}
if (job.status === 'completed' || job.status === 'failed') {
return job;
}
try {
let pagePlan = job?.pagePlan && typeof job.pagePlan === 'object' ? job.pagePlan : null;
if (!pagePlan) {
job.status = 'crawling';
job.stage = 'crawling';
await saveOnboardingJob(job);
pagePlan = await buildBrandContextPlan(job.websiteUrl);
job.pagePlan = pagePlan;
job.progress = {
...(job.progress || {}),
pagesProcessed: 1,
pagesDiscovered: 1
+ (pagePlan.aboutUrl ? 1 : 0)
+ (Array.isArray(pagePlan.productUrls) ? pagePlan.productUrls.length : 0)
+ (pagePlan.discoveryUrl ? 1 : 0),
imageCount: Array.isArray(pagePlan.homepage?.images) ? pagePlan.homepage.images.length : 0,
linkCount: Array.isArray(pagePlan.homepage?.links) ? pagePlan.homepage.links.length : 0,
};
return saveOnboardingJob(job);
}
let crawlSummary = job?.crawlSummary && typeof job.crawlSummary === 'object' ? job.crawlSummary : null;
if (!crawlSummary) {
job.status = 'summarizing';
job.stage = 'summarizing';
await saveOnboardingJob(job);
const pageSet = await collectBrandContextPages(pagePlan);
crawlSummary = buildCrawlSummary(pageSet, job.websiteUrl);
job.crawlSummary = crawlSummary;
delete job.pagePlan;
job.progress = {
...(job.progress || {}),
pagesProcessed: crawlSummary.pageCount || 0,
pagesDiscovered: crawlSummary.pageCount || 0,
representativePages: Array.isArray(crawlSummary.representativePages) ? crawlSummary.representativePages.length : 0,
imageCount: Array.isArray(crawlSummary.topImages) ? crawlSummary.topImages.length : 0,
linkCount: Array.isArray(crawlSummary.links) ? crawlSummary.links.length : 0,
};
return saveOnboardingJob(job);
}
let brandContext = job?.brandContext && typeof job.brandContext === 'object' ? job.brandContext : null;
if (!brandContext) {
job.status = 'parsing_brand';
job.stage = 'parsing_brand';
await saveOnboardingJob(job);
brandContext = await parseBrandContext(crawlSummary);
job.brandContext = brandContext;
job.status = 'finalizing_business';
job.stage = 'finalizing_business';
return saveOnboardingJob(job);
}
job.status = 'finalizing_business';
job.stage = 'finalizing_business';
await saveOnboardingJob(job);
const result = await finalizeBusinessFromCrawlJob(job, crawlSummary, brandContext);
job.status = 'completed';
job.stage = 'completed';
job.business = result.business;
job.error = null;
return saveOnboardingJob(job);
} catch (error) {
job.status = 'failed';
job.stage = 'failed';
job.error = {
message: error.message || 'Business onboarding failed',
};
return saveOnboardingJob(job);
}
}
const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']); const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']);
const EVENT_TEMPLATE_FALLBACKS = { const EVENT_TEMPLATE_FALLBACKS = {
bag_confirmed: ['confirmed'], bag_confirmed: ['confirmed'],
@ -818,7 +1062,7 @@ router.get('/', async (req, res) => {
} }
}); });
// POST /api/businesses — create new business from websiteUrl with optional applicationId // POST /api/businesses — start async business onboarding from websiteUrl with optional applicationId
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
try { try {
const merchantId = getCompanyId(req); const merchantId = getCompanyId(req);
@ -841,87 +1085,58 @@ router.post('/', async (req, res) => {
{ code: 'MISSING_WEBSITE_URL' } { code: 'MISSING_WEBSITE_URL' }
); );
} }
const businesses = await getIndex(merchantId);
const businesses = await getIndex(merchantId);
if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) { if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) {
return res.status(409).json({ error: 'A business is already configured for this applicationId' }); return res.status(409).json({ error: 'A business is already configured for this applicationId' });
} }
const businessId = uuidv4(); const now = new Date().toISOString();
const bizRoot = businessRoot(merchantId, businessId); const job = await saveOnboardingJob({
const imagesFolder = `${bizRoot}/images`; jobId: uuidv4(),
companyId: merchantId,
applicationId,
websiteUrl,
status: 'queued',
stage: 'queued',
progress: {
pagesProcessed: 0,
pagesDiscovered: 0,
representativePages: 0,
imageCount: 0,
linkCount: 0,
},
crawlSummary: null,
brandContext: null,
business: null,
error: null,
createdAt: now,
updatedAt: now,
});
// 1. Scrape res.status(202).json(buildJobResponse(job));
const scrapedData = await scrape(websiteUrl); } catch (err) {
console.error('Start business onboarding error:', err.message);
sendRouteError(res, err);
}
});
// 2. Parse brand context // GET /api/businesses/jobs/:jobId
const brandContext = await parseBrandContext(scrapedData); router.get('/jobs/:jobId', async (req, res) => {
try {
// 3. Upload relevant images const companyId = getCompanyId(req);
const imagePaths = []; if (!companyId) {
for (let i = 0; i < Math.min((brandContext.relevantImageUrls || []).length, 5); i++) { throw createHttpError(400, 'companyId is required');
const url = await uploadImageFromUrl(brandContext.relevantImageUrls[i], imagesFolder, `image_${i + 1}`);
if (url) imagePaths.push(url);
} }
// 4. Build and upload context.json const job = await loadOnboardingJobWithRetry(companyId, req.params.jobId);
let domain = ''; if (!job) {
try { domain = new URL(websiteUrl).hostname; } catch { } throw createHttpError(404, 'Onboarding job not found');
}
const contextJson = { const updatedJob = await advanceOnboardingJob(job);
businessId, res.json(buildJobResponse(updatedJob));
merchantId,
companyId: merchantId,
applicationId,
domain,
brandName: brandContext.brandName || 'Unknown Brand',
tone: brandContext.tone || 'professional',
taglines: brandContext.taglines || [],
colors: brandContext.colors || [],
relevantImagePaths: imagePaths,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await uploadJSON(bizRoot, 'context', contextJson);
const scrapeArtifacts = {
cdnUrls: normalizeUrlList(imagePaths),
links: Array.isArray(scrapedData.links) ? scrapedData.links : [],
json: scrapedData?.json && typeof scrapedData.json === 'object'
? scrapedData.json
: {
markdown: scrapedData.markdown || '',
links: Array.isArray(scrapedData.links) ? scrapedData.links : [],
metadata: scrapedData.metadata || {},
images: Array.isArray(scrapedData.images) ? scrapedData.images : [],
},
};
// 5. Init events.json
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
// 6. Update index.json
const previewSummary = getBusinessPreviewSummary(contextJson);
businesses.push({
businessId,
companyId: merchantId,
applicationId,
brandName: contextJson.brandName,
domain: contextJson.domain,
previewTagline: previewSummary.previewTagline,
previewImagePath: previewSummary.previewImagePath,
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
createdAt: contextJson.createdAt,
updatedAt: contextJson.updatedAt,
});
await saveIndex(merchantId, businesses);
res.json({
...contextJson,
scrapeArtifacts,
});
} catch (err) { } catch (err) {
console.error('Create business error:', err.message);
sendRouteError(res, err); sendRouteError(res, err);
} }
}); });
@ -930,9 +1145,23 @@ router.post('/', async (req, res) => {
router.get('/:businessId', async (req, res) => { router.get('/:businessId', async (req, res) => {
try { try {
const { businessId } = req.params; const { businessId } = req.params;
const context = await fetchJSON(businessRoot(getCompanyId(req), businessId), 'context'); const merchantId = getCompanyId(req);
const root = businessRoot(merchantId, businessId);
const [context, crawlSummary] = await Promise.all([
fetchJSON(root, 'context'),
fetchJSON(root, 'crawl_summary').catch(() => null),
]);
if (!context) return res.status(404).json({ error: 'Business not found' }); if (!context) return res.status(404).json({ error: 'Business not found' });
res.json(context);
if (!crawlSummary) {
return res.json(context);
}
res.json({
...context,
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, context.relevantImagePaths),
});
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -0,0 +1,225 @@
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeList(value) {
return Array.isArray(value) ? value : [];
}
function uniqueStrings(values) {
const seen = new Set();
return normalizeList(values)
.map((value) => normalizeText(value))
.filter((value) => {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function isHexColor(value) {
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(normalizeText(value));
}
function toColorEntry(name, value) {
const hex = normalizeText(value);
if (!isHexColor(hex)) return null;
return {
name: normalizeText(name) || 'color',
hex: hex.toUpperCase(),
};
}
function extractHostname(url) {
try {
return new URL(url).hostname.replace(/^www\./i, '').toLowerCase();
} catch {
return '';
}
}
function excerptText(page) {
const summary = normalizeText(page?.summary);
if (summary) return summary.slice(0, 800);
return normalizeText(page?.markdown)
.replace(/\n{3,}/g, '\n\n')
.slice(0, 1600);
}
function normalizeLinkItem(link) {
if (typeof link === 'string') {
const href = normalizeText(link);
return href ? { href, label: href } : null;
}
if (!link || typeof link !== 'object') return null;
const href = normalizeText(link.href || link.url || link.link);
if (!href) return null;
return {
href,
label: normalizeText(link.text || link.title || link.label) || href,
};
}
function dedupeLinks(links) {
const seen = new Set();
return normalizeList(links)
.map(normalizeLinkItem)
.filter((link) => {
if (!link || seen.has(link.href)) return false;
seen.add(link.href);
return true;
});
}
function summarizePage(page, pageType) {
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
return {
url: normalizeText(page?.url),
type: pageType,
title: normalizeText(metadata.title || metadata.ogTitle),
description: normalizeText(metadata.description || metadata.ogDescription),
excerpt: excerptText(page),
linkCount: normalizeList(page?.links).length,
imageCount: uniqueStrings(page?.images).length,
};
}
function buildRepresentativeTextBlocks(homepage, aboutPage, productPages) {
return [homepage, aboutPage, ...productPages]
.filter(Boolean)
.map((page) => ({
url: page.url,
title: page.title,
pageType: page.type,
text: page.excerpt,
}));
}
function flattenBranding(homepage) {
const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {};
const colorEntries = [];
const logos = [];
const brandNames = [];
const colorSource = branding.colors || branding.colorPalette || branding.palette;
if (Array.isArray(colorSource)) {
colorSource.forEach((color, index) => {
if (typeof color === 'string') {
const entry = toColorEntry(`color_${index + 1}`, color);
if (entry) colorEntries.push(entry);
return;
}
if (color && typeof color === 'object') {
const entry = toColorEntry(
color.name || color.label || color.role || `color_${index + 1}`,
color.hex || color.value || color.color
);
if (entry) colorEntries.push(entry);
}
});
} else if (colorSource && typeof colorSource === 'object') {
Object.entries(colorSource).forEach(([name, value]) => {
const entry = toColorEntry(name, value);
if (entry) colorEntries.push(entry);
});
}
normalizeList(branding.logos || branding.logoUrls || branding.logo_urls).forEach((logo) => {
if (typeof logo === 'string') {
logos.push(logo);
} else if (logo && typeof logo === 'object') {
logos.push(logo.url || logo.src || '');
}
});
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
if (brandName) brandNames.push(brandName);
return {
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
labeledColors: colorEntries.filter((entry, index, values) => (
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
)),
logos: uniqueStrings(logos),
brandNames: uniqueStrings(brandNames),
};
}
function buildSummaryText(startUrl, homepage, aboutPage, productPages) {
const blocks = [`Site: ${startUrl}`];
[homepage, aboutPage, ...productPages].filter(Boolean).forEach((page, index) => {
blocks.push([
`Page ${index + 1}: ${page.title || page.url}`,
`Type: ${page.type}`,
page.description ? `Description: ${page.description}` : '',
page.excerpt ? `Excerpt: ${page.excerpt}` : '',
].filter(Boolean).join('\n'));
});
return blocks.join('\n\n').slice(0, 24000);
}
function buildCrawlSummary(data = {}, startUrlOverride = '') {
const startUrl = normalizeText(startUrlOverride || data.startUrl);
const homepageRaw = data.homepage || null;
const aboutRaw = data.aboutPage || null;
const productRawPages = normalizeList(data.productPages);
const domain = extractHostname(startUrl || homepageRaw?.url || '');
const homepage = homepageRaw ? summarizePage(homepageRaw, 'home') : null;
const aboutPage = aboutRaw ? summarizePage(aboutRaw, 'about') : null;
const productPages = productRawPages.map((page) => summarizePage(page, 'product'));
const representativePages = [homepage, aboutPage, ...productPages].filter(Boolean);
const representativeTextBlocks = buildRepresentativeTextBlocks(homepage, aboutPage, productPages);
const homepageLinks = dedupeLinks(data?.links?.homepage || homepageRaw?.links || []);
const discoveryLinks = dedupeLinks(data?.links?.discovery || []);
const links = dedupeLinks([...homepageLinks, ...discoveryLinks]);
const topImages = uniqueStrings([
...normalizeList(homepageRaw?.images),
...normalizeList(aboutRaw?.images),
...productRawPages.flatMap((page) => normalizeList(page?.images)),
]).slice(0, 60);
const branding = flattenBranding(homepageRaw);
return {
startUrl,
domain,
pageCount: representativePages.length,
siteStats: {
totalPages: representativePages.length,
totalLinks: links.length,
totalImages: topImages.length,
aboutPages: aboutPage ? 1 : 0,
productPages: productPages.length,
},
homepage,
aboutPage,
contactPage: null,
policyPages: [],
productPages,
representativePages,
representativeTextBlocks,
keyPages: {
about: aboutPage ? [aboutPage] : [],
products: productPages,
},
navigation: homepageLinks.slice(0, 30),
links,
socialLinks: links.filter((link) => /instagram|facebook|x\.com|twitter|linkedin|youtube|pinterest/i.test(link.href)),
topImages,
screenshots: [],
branding,
summaryText: buildSummaryText(startUrl, homepage, aboutPage, productPages),
};
}
module.exports = { buildCrawlSummary };

View File

@ -1,44 +1,273 @@
const axios = require('axios'); const axios = require('axios');
/** const FIRECRAWL_BASE_URL = 'https://api.firecrawl.dev/v2';
* Scrape a website using Firecrawl. const DEFAULT_PRODUCT_PAGE_LIMIT = 5;
* Returns normalized fields plus the raw response payload for downstream UI/debug use. const HOMEPAGE_FORMATS = ['markdown', 'links', 'images', 'branding'];
*/ const DISCOVERY_PAGE_FORMATS = ['markdown', 'links', 'images'];
async function scrape(url) { const CONTENT_PAGE_FORMATS = ['markdown', 'images'];
function getApiKey() {
const apiKey = process.env.FIRECRAWL_API_KEY; const apiKey = process.env.FIRECRAWL_API_KEY;
if (!apiKey) throw new Error('FIRECRAWL_API_KEY not set'); if (!apiKey) throw new Error('FIRECRAWL_API_KEY not set');
return apiKey;
}
try { function getClient() {
const response = await axios.post( return axios.create({
'https://api.firecrawl.dev/v1/scrape', baseURL: FIRECRAWL_BASE_URL,
{ url, formats: ['markdown', 'links'] }, timeout: 60000,
{
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${getApiKey()}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 30000, validateStatus: () => true,
});
}
function normalizeDataEnvelope(data) {
if (data && typeof data === 'object' && data.data && typeof data.data === 'object') {
return data.data;
} }
); return data && typeof data === 'object' ? data : {};
}
const data = response.data?.data || response.data; function normalizeText(value) {
const markdownLen = typeof data?.markdown === 'string' ? data.markdown.length : 0; return typeof value === 'string' ? value.trim() : '';
const linksCount = Array.isArray(data?.links) ? data.links.length : 0; }
console.log( function normalizeUrl(rawUrl, baseUrl = '') {
`[Firecrawl] scrape success | status=${response.status} | markdownLen=${markdownLen} | links=${linksCount}` const value = normalizeText(rawUrl);
); if (!value) return '';
return { try {
markdown: typeof data?.markdown === 'string' ? data.markdown : '', const url = baseUrl ? new URL(value, baseUrl) : new URL(value);
links: Array.isArray(data?.links) ? data.links : [], url.hash = '';
metadata: data?.metadata && typeof data.metadata === 'object' ? data.metadata : {}, url.search = '';
images: Array.isArray(data?.images) ? data.images : [], return url.toString().replace(/\/$/, '');
json: data && typeof data === 'object' ? data : {}, } catch {
}; return '';
} catch (err) {
throw err;
} }
} }
module.exports = { scrape }; function normalizePageResult(page = {}, pageType = '') {
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
return {
url: normalizeUrl(page.url),
pageType: normalizeText(pageType || page.pageType),
markdown: typeof page.markdown === 'string' ? page.markdown : '',
summary: typeof page.summary === 'string' ? page.summary : '',
metadata,
links: Array.isArray(page.links) ? page.links : [],
images: Array.isArray(page.images) ? page.images : [],
branding: page?.branding && typeof page.branding === 'object' ? page.branding : {},
};
}
function normalizeLinkItem(link, baseUrl = '') {
if (typeof link === 'string') {
const href = normalizeUrl(link, baseUrl);
return href ? { href, label: href } : null;
}
if (!link || typeof link !== 'object') return null;
const href = normalizeUrl(link.href || link.url || link.link, baseUrl);
if (!href) return null;
return {
href,
label: normalizeText(link.text || link.title || link.label) || href,
};
}
function dedupeLinkItems(links, baseUrl = '') {
const seen = new Set();
return (Array.isArray(links) ? links : [])
.map((link) => normalizeLinkItem(link, baseUrl))
.filter((link) => {
if (!link || seen.has(link.href)) return false;
seen.add(link.href);
return true;
});
}
function getHostname(url) {
try {
return new URL(url).hostname;
} catch {
return '';
}
}
function getPathname(url) {
try {
return new URL(url).pathname.toLowerCase();
} catch {
return '';
}
}
function isSameDomain(url, startUrl) {
return getHostname(url) === getHostname(startUrl);
}
function isUtilityLink(link) {
const value = `${normalizeText(link?.href)} ${normalizeText(link?.label)}`.toLowerCase();
return /(login|sign[- ]?in|sign[- ]?up|account|cart|checkout|wishlist|track|tracking|privacy|refund|return|shipping|terms|policy|policies|contact|support|help|faq|blog|blogs|journal|careers?|gift card|stores?)/i.test(value);
}
function isAboutLink(link) {
const value = `${normalizeText(link?.href)} ${normalizeText(link?.label)}`.toLowerCase();
return /(about|about-us|our story|our-story|story|brand story|who we are|who-we-are)/i.test(value);
}
function isDiscoveryLink(link) {
const value = `${normalizeText(link?.href)} ${normalizeText(link?.label)}`.toLowerCase();
return /(shop|products?|collections?|catalog|storefront|category|categories|new arrivals|best sellers|featured)/i.test(value);
}
function scoreProductCandidate(link) {
if (!link || isUtilityLink(link) || isAboutLink(link) || isDiscoveryLink(link)) return -100;
const href = normalizeText(link.href).toLowerCase();
const label = normalizeText(link.label);
const pathname = getPathname(href);
const segments = pathname.split('/').filter(Boolean);
let score = 0;
if (/(^|\/)(product|products|p|item|items|buy)(\/|$)/i.test(pathname)) score += 12;
if (/-\d{4,}$/.test(pathname) || /\d{4,}/.test(pathname)) score += 5;
if (pathname.includes('/collections/') || pathname.includes('/category/')) score -= 8;
if (segments.length >= 2) score += 3;
if (label.length >= 8 && label.length <= 120) score += 3;
if (/buy|shop now|view product|details/i.test(label)) score += 4;
if (href.split('-').length >= 4) score += 2;
return score;
}
function getSameDomainLinks(links, startUrl) {
return dedupeLinkItems(links, startUrl).filter((link) => isSameDomain(link.href, startUrl));
}
function selectAboutUrl(links, startUrl) {
return getSameDomainLinks(links, startUrl).find(isAboutLink)?.href || '';
}
function selectDiscoveryUrl(links, startUrl) {
return getSameDomainLinks(links, startUrl)
.filter((link) => isDiscoveryLink(link) && !isUtilityLink(link))
.map((link) => link.href)[0] || '';
}
function selectProductUrls(links, startUrl, limit = DEFAULT_PRODUCT_PAGE_LIMIT) {
return getSameDomainLinks(links, startUrl)
.map((link) => ({ ...link, score: scoreProductCandidate(link) }))
.filter((link) => link.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, limit)
.map((link) => link.href);
}
async function scrapePage(url, { formats, onlyMainContent = true, pageType = '' } = {}) {
const client = getClient();
const response = await client.post('/scrape', {
url,
formats,
onlyMainContent,
});
if (response.status < 200 || response.status >= 300) {
throw new Error(`Firecrawl scrape failed for ${url} with status ${response.status}: ${JSON.stringify(response.data)}`);
}
return normalizePageResult(normalizeDataEnvelope(response.data), pageType);
}
async function buildBrandContextPlan(startUrl) {
const normalizedStartUrl = normalizeUrl(startUrl);
const homepage = await scrapePage(normalizedStartUrl, {
formats: HOMEPAGE_FORMATS,
onlyMainContent: false,
pageType: 'home',
});
const homepageLinks = getSameDomainLinks(homepage.links, normalizedStartUrl);
const aboutUrl = selectAboutUrl(homepageLinks, normalizedStartUrl);
const directProductUrls = selectProductUrls(homepageLinks, normalizedStartUrl);
const discoveryUrl = directProductUrls.length === 0
? selectDiscoveryUrl(homepageLinks, normalizedStartUrl)
: '';
return {
startUrl: normalizedStartUrl,
homepage,
aboutUrl,
discoveryUrl,
productUrls: directProductUrls,
};
}
async function collectBrandContextPages(plan) {
const startUrl = normalizeUrl(plan?.startUrl);
if (!startUrl || !plan?.homepage) {
throw new Error('Brand context plan is missing a homepage scrape');
}
let discoveryPage = null;
let productUrls = Array.isArray(plan.productUrls) ? plan.productUrls.slice(0, DEFAULT_PRODUCT_PAGE_LIMIT) : [];
if (productUrls.length === 0 && normalizeText(plan.discoveryUrl)) {
discoveryPage = await scrapePage(plan.discoveryUrl, {
formats: DISCOVERY_PAGE_FORMATS,
onlyMainContent: true,
pageType: 'discovery',
});
productUrls = selectProductUrls(discoveryPage.links, startUrl);
}
const aboutPromise = normalizeText(plan.aboutUrl)
? scrapePage(plan.aboutUrl, {
formats: CONTENT_PAGE_FORMATS,
onlyMainContent: true,
pageType: 'about',
})
: Promise.resolve(null);
const productPromises = productUrls
.slice(0, DEFAULT_PRODUCT_PAGE_LIMIT)
.map((productUrl) => scrapePage(productUrl, {
formats: CONTENT_PAGE_FORMATS,
onlyMainContent: true,
pageType: 'product',
}).catch(() => null));
const [aboutPage, productResults] = await Promise.all([
aboutPromise,
Promise.all(productPromises),
]);
const productPages = productResults.filter(Boolean);
const items = [
plan.homepage,
aboutPage,
discoveryPage,
...productPages,
].filter(Boolean);
return {
startUrl,
homepage: plan.homepage,
aboutPage,
discoveryPage,
productPages,
items,
};
}
module.exports = {
scrapePage,
buildBrandContextPlan,
collectBrandContextPages,
};

View File

@ -50,18 +50,47 @@ async function postWorkflow(url, payload) {
} }
async function parseBrandContext(scrapedData = {}) { async function parseBrandContext(scrapedData = {}) {
const markdown = String(scrapedData.markdown || '').slice(0, 8000); const representativePages = Array.isArray(scrapedData.representativePages)
const links = Array.isArray(scrapedData.links) ? scrapedData.links.slice(0, 200) : []; ? scrapedData.representativePages.slice(0, 20)
: [];
const representativeTextBlocks = Array.isArray(scrapedData.representativeTextBlocks)
? scrapedData.representativeTextBlocks.slice(0, 20)
: [];
const productPages = Array.isArray(scrapedData.productPages)
? scrapedData.productPages.slice(0, 5)
: [];
const contentDigest = representativeTextBlocks
.map((block) => {
const title = String(block?.title || '').trim();
const pageType = String(block?.pageType || '').trim();
const text = String(block?.text || '').trim();
return [title, pageType, text].filter(Boolean).join(' | ');
})
.filter(Boolean)
.join('\n\n')
.slice(0, 14000);
const payload = { const payload = {
task: 'parse_brand_context', task: 'parse_brand_context',
request_id: requestId('parse_brand_context'), request_id: requestId('parse_brand_context'),
markdown, start_url: String(scrapedData.startUrl || ''),
links_json: JSON.stringify(links), domain: String(scrapedData.domain || ''),
metadata_json: JSON.stringify(scrapedData.metadata || {}), site_stats_json: JSON.stringify(scrapedData.siteStats || {}),
images_json: JSON.stringify(scrapedData.images || []), homepage_json: JSON.stringify(scrapedData.homepage || {}),
raw_json_blob: JSON.stringify(scrapedData.json || {}), about_page_json: JSON.stringify(scrapedData.aboutPage || {}),
output_schema_text: 'Return ONLY valid JSON object with exactly these keys: brandName (string), tone (one of: friendly, professional, formal, casual, energetic), taglines (array of strings, max 3), colors (array of hex color strings, or empty array), relevantImageUrls (array of 3-5 absolute image URLs for logo/hero/product images only; no icons/tracking/data URLs). No markdown, no prose, no extra keys.', product_pages_json: JSON.stringify(productPages),
contact_page_json: JSON.stringify(scrapedData.contactPage || {}),
representative_pages_json: JSON.stringify(representativePages),
representative_text_blocks_json: JSON.stringify(representativeTextBlocks),
navigation_json: JSON.stringify(scrapedData.navigation || []),
policy_pages_json: JSON.stringify(scrapedData.policyPages || []),
links_json: JSON.stringify(scrapedData.links || []),
top_images_json: JSON.stringify(scrapedData.topImages || []),
screenshots_json: JSON.stringify(scrapedData.screenshots || []),
branding_json: JSON.stringify(scrapedData.branding || {}),
crawl_summary_json: JSON.stringify(scrapedData || {}),
content_digest: contentDigest,
output_schema_text: 'You are given homepage, about-page, product-page, branding, and image evidence for a storefront. Use that evidence to infer brand identity and product language. Return ONLY valid JSON object with exactly these keys: brandName (string), tone (one of: friendly, professional, formal, casual, energetic), taglines (array of strings, max 3), colors (array of hex color strings, or empty array), relevantImageUrls (array of 3-5 absolute image URLs for logo/hero/product images only; no icons/tracking/data URLs), aboutSummary (string, 2-4 sentences, concise customer-facing brand summary that explains what the brand is about, what it sells, and its vibe; do not copy the About Us page verbatim). No markdown, no prose, no extra keys.',
must_return_json_only: 'true', must_return_json_only: 'true',
}; };
@ -76,6 +105,7 @@ async function parseBrandContext(scrapedData = {}) {
taglines: Array.isArray(output.taglines) ? output.taglines.slice(0, 3).map(String) : [], taglines: Array.isArray(output.taglines) ? output.taglines.slice(0, 3).map(String) : [],
colors: Array.isArray(output.colors) ? output.colors.map(String) : [], colors: Array.isArray(output.colors) ? output.colors.map(String) : [],
relevantImageUrls: Array.isArray(output.relevantImageUrls) ? output.relevantImageUrls.map(String) : [], relevantImageUrls: Array.isArray(output.relevantImageUrls) ? output.relevantImageUrls.map(String) : [],
aboutSummary: String(output.aboutSummary || '').trim(),
}; };
} }