Business page visual, view brand buttons, and other stuff
This commit is contained in:
parent
6bbfc1b740
commit
93e0b32454
|
|
@ -1,5 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { BusinessProvider, useBusiness } from './context/BusinessContext';
|
||||
import apiClient from './api/client';
|
||||
import BusinessReviewModal from './components/BusinessReviewModal';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Businesses from './pages/Businesses';
|
||||
import Providers from './pages/Providers';
|
||||
|
|
@ -9,10 +12,40 @@ import Templates from './pages/Templates';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
<header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
|
||||
{hasGlobalSms && (
|
||||
|
|
@ -30,6 +63,14 @@ function SubLayout({ children }) {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{reviewBusiness && (
|
||||
<BusinessReviewModal
|
||||
business={reviewBusiness}
|
||||
onClose={() => setReviewBusiness(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +1,130 @@
|
|||
import { useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 [status, setStatus] = useState('idle');
|
||||
const [error, setError] = useState('');
|
||||
const [job, setJob] = useState(null);
|
||||
const pollTimerRef = useRef(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!url.trim()) return;
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (pollTimerRef.current) window.clearTimeout(pollTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
setStatus('loading');
|
||||
setError('');
|
||||
function clearPolling() {
|
||||
if (pollTimerRef.current) {
|
||||
window.clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll(initialJob) {
|
||||
clearPolling();
|
||||
let transientNotFoundMisses = 0;
|
||||
let currentJob = initialJob;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await apiClient.post('/api/businesses', {
|
||||
websiteUrl: url.trim(),
|
||||
});
|
||||
const nextJob = await fetchBusinessOnboardingJob(initialJob.jobId);
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (typeof onSuccess === 'function') {
|
||||
await onSuccess(res.data);
|
||||
transientNotFoundMisses = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
if (nextJob.status === 'failed') {
|
||||
clearPolling();
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimerRef.current = window.setTimeout(tick, 2200);
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<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">
|
||||
|
||||
{status === 'success' && (
|
||||
{showSuccessScreen && (
|
||||
<div>
|
||||
<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">
|
||||
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>
|
||||
<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>
|
||||
|
|
@ -49,17 +134,37 @@ export default function RegisterBusinessModal({ onClose, onSuccess }) {
|
|||
onClick={onClose}
|
||||
className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
|
||||
>
|
||||
Next
|
||||
Continue
|
||||
</button>
|
||||
</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">
|
||||
<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">
|
||||
Enter the storefront website URL and we'll scrape it to detect the brand and set up your business.
|
||||
Enter the storefront website URL and we'll scrape the homepage, about page, and representative product pages to detect the brand and set up your business.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -71,45 +176,99 @@ export default function RegisterBusinessModal({ onClose, onSuccess }) {
|
|||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<p className="text-sm text-red-600 font-medium bg-white border border-gray-200 rounded-lg px-3 py-2">{error}</p>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Analysing…</>
|
||||
) : 'Add Business'}
|
||||
{status === 'starting' ? (
|
||||
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Starting crawl…</>
|
||||
) : 'Start setup'}
|
||||
</button>
|
||||
</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 20–30 seconds.
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ function StageMarker({ done, active, enabled }) {
|
|||
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 {
|
||||
activeBusiness,
|
||||
activeBusinessId,
|
||||
|
|
@ -132,13 +132,43 @@ export default function Sidebar() {
|
|||
<span>Switch Business</span>
|
||||
</button>
|
||||
{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 ">
|
||||
{activeBusiness.brandName?.[0]?.toUpperCase() || 'B'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBrand } from '../context/BrandContext';
|
||||
import BusinessReviewModal from '../components/BusinessReviewModal';
|
||||
import RegisterBusinessModal from '../components/RegisterBusinessModal';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) {
|
|||
export default function Brand() {
|
||||
const { brand, loading, refetch } = useBrand();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showReviewModal, setShowReviewModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
|
@ -122,59 +124,7 @@ export default function Brand() {
|
|||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
|
|
@ -196,6 +146,13 @@ export default function Brand() {
|
|||
)}
|
||||
</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 && (
|
||||
<DeleteConfirmModal
|
||||
brandName={brand.brandName}
|
||||
|
|
@ -204,6 +161,18 @@ export default function Brand() {
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,16 @@ import { useNavigate } from 'react-router-dom';
|
|||
import apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
import RegisterBusinessModal from '../components/RegisterBusinessModal';
|
||||
import BusinessReviewModal from '../components/BusinessReviewModal';
|
||||
import { fetchActiveSalesChannels } from '../utils/fyndSalesChannels';
|
||||
import {
|
||||
fetchBusinessOnboardingJob,
|
||||
getBusinessOnboardingError,
|
||||
getBusinessOnboardingProgress,
|
||||
getBusinessOnboardingStageMeta,
|
||||
shouldRetryMissingBusinessOnboardingJob,
|
||||
startBusinessOnboardingJob,
|
||||
} from '../utils/businessOnboarding';
|
||||
import {
|
||||
getApplicationId,
|
||||
getBusinessDomain,
|
||||
|
|
@ -16,106 +25,6 @@ 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 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 }) {
|
||||
return (
|
||||
<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 }) {
|
||||
const name = getBusinessName(business);
|
||||
const domain = getBusinessDomain(business);
|
||||
const tagline = getBusinessTagline(business);
|
||||
const image = getBusinessImage(business);
|
||||
const cdnUrls = extractCdnUrls(business?.scrapeArtifacts?.cdnUrls?.length ? { relevantImagePaths: business.scrapeArtifacts.cdnUrls } : 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;
|
||||
};
|
||||
}, []);
|
||||
function BusinessOnboardingModal({ job, onClose }) {
|
||||
const status = normalizeText(job?.status);
|
||||
const stageMeta = getBusinessOnboardingStageMeta(job?.stage || status);
|
||||
const progress = getBusinessOnboardingProgress(job);
|
||||
const isFailed = status === 'failed';
|
||||
const isCompleted = status === 'completed';
|
||||
const discoveredPages = progress.pagesDiscovered;
|
||||
const processedPages = progress.pagesProcessed;
|
||||
const progressWidth = stageMeta.percent;
|
||||
const errorMessage = getBusinessOnboardingError(job);
|
||||
|
||||
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 created</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{name}</h2>
|
||||
<div className="w-full max-w-lg rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="border-b border-gray-200 px-6 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">
|
||||
{isFailed ? 'Onboarding failed' : isCompleted ? 'Business ready' : 'Setting up business'}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{stageMeta.label}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{domain
|
||||
? `Scrape completed for ${domain}. Review the captured assets below before moving on.`
|
||||
: 'Scrape completed. Review the captured assets below before moving on.'}
|
||||
{isFailed
|
||||
? 'The onboarding job could not be completed. You can close this dialog and try again.'
|
||||
: isCompleted
|
||||
? 'The storefront crawl and brand analysis finished successfully.'
|
||||
: stageMeta.note}
|
||||
</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 overscroll-contain 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="w-16 h-16 rounded-xl overflow-hidden bg-white border border-gray-200 shrink-0 flex items-center justify-center">
|
||||
{image ? (
|
||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{!isFailed && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
<span>Preparing crawl</span>
|
||||
)}
|
||||
</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
|
||||
onClick={onClose}
|
||||
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
|
||||
disabled
|
||||
className="w-full rounded-lg bg-gray-100 py-2 text-sm font-medium text-gray-500"
|
||||
>
|
||||
Working…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -314,10 +174,12 @@ function UnifiedBusinessCard({
|
|||
item,
|
||||
selectingBusinessId,
|
||||
creatingSalesChannelId,
|
||||
reviewLoadingBusinessId,
|
||||
onSelect,
|
||||
onImport,
|
||||
onDelete,
|
||||
onFallback,
|
||||
onReview,
|
||||
}) {
|
||||
const entity = item.business || item.channel;
|
||||
const businessId = item.business?.businessId || '';
|
||||
|
|
@ -327,9 +189,9 @@ function UnifiedBusinessCard({
|
|||
const domain = getBusinessDomain(entity);
|
||||
const tagline = getBusinessTagline(entity);
|
||||
const isScraped = item.status === 'scraped';
|
||||
const cdnUrls = extractCdnUrls(item.business);
|
||||
const isOpening = isScraped && selectingBusinessId === businessId;
|
||||
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
||||
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
|
||||
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
|
||||
const canOpenBusiness = isScraped && item.business && !isOpening;
|
||||
|
||||
|
|
@ -393,17 +255,6 @@ function UnifiedBusinessCard({
|
|||
</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 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
|
||||
</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}
|
||||
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>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{hasWebsiteUrl ? 'Ready to scrape' : 'Needs manual URL'}
|
||||
{hasWebsiteUrl ? 'Ready to onboard' : 'Needs manual URL'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -455,10 +316,15 @@ export default function Businesses() {
|
|||
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
||||
const [creatingSalesChannelId, setCreatingSalesChannelId] = useState('');
|
||||
const [createdBusiness, setCreatedBusiness] = useState(null);
|
||||
const [onboardingJob, setOnboardingJob] = useState(null);
|
||||
const [showOnboardingModal, setShowOnboardingModal] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reviewBusiness, setReviewBusiness] = useState(null);
|
||||
const [reviewLoadingBusinessId, setReviewLoadingBusinessId] = useState('');
|
||||
const onboardingJobCreatedAt = onboardingJob?.createdAt;
|
||||
|
||||
const showUnifiedSalesChannelView = salesChannelsStatus === 'success';
|
||||
|
||||
|
|
@ -557,8 +423,9 @@ export default function Businesses() {
|
|||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleBusinessCreated(created) {
|
||||
const handleBusinessCreated = useCallback(async (created) => {
|
||||
setShowModal(false);
|
||||
setShowOnboardingModal(false);
|
||||
setCreatedBusiness(created);
|
||||
|
||||
try {
|
||||
|
|
@ -567,7 +434,73 @@ export default function Businesses() {
|
|||
} catch (err) {
|
||||
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) {
|
||||
setSelectingBusinessId(biz.businessId);
|
||||
|
|
@ -595,11 +528,11 @@ export default function Businesses() {
|
|||
setError('');
|
||||
|
||||
try {
|
||||
const res = await apiClient.post('/api/businesses', {
|
||||
const job = await startBusinessOnboardingJob({
|
||||
applicationId,
|
||||
websiteUrl: channel.websiteUrl,
|
||||
});
|
||||
await handleBusinessCreated(res.data);
|
||||
await handleBusinessJobStarted(job);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to add business from sales channel');
|
||||
} 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) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||
|
|
@ -639,8 +593,8 @@ export default function Businesses() {
|
|||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{showUnifiedSalesChannelView
|
||||
? 'View every connected sales channel in one place and scrape the ones that are not onboarded yet.'
|
||||
: 'Add a storefront URL and we’ll scrape it to set up your business.'}
|
||||
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
|
||||
: 'Add a storefront URL and we’ll scrape the homepage, about page, and representative product pages to set up your business.'}
|
||||
</p>
|
||||
</div>
|
||||
{!showUnifiedSalesChannelView && (
|
||||
|
|
@ -687,10 +641,12 @@ export default function Businesses() {
|
|||
item={item}
|
||||
selectingBusinessId={selectingBusinessId}
|
||||
creatingSalesChannelId={creatingSalesChannelId}
|
||||
reviewLoadingBusinessId={reviewLoadingBusinessId}
|
||||
onSelect={handleSelect}
|
||||
onImport={handleCreateFromSalesChannel}
|
||||
onDelete={setDeleteTarget}
|
||||
onFallback={() => setShowModal(true)}
|
||||
onReview={handleOpenReview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -730,10 +686,12 @@ export default function Businesses() {
|
|||
item={{ key: `fallback:${biz.businessId}`, status: 'scraped', business: biz, channel: null }}
|
||||
selectingBusinessId={selectingBusinessId}
|
||||
creatingSalesChannelId={creatingSalesChannelId}
|
||||
reviewLoadingBusinessId={reviewLoadingBusinessId}
|
||||
onSelect={handleSelect}
|
||||
onImport={handleCreateFromSalesChannel}
|
||||
onDelete={setDeleteTarget}
|
||||
onFallback={() => setShowModal(true)}
|
||||
onReview={handleOpenReview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -750,10 +708,32 @@ export default function Businesses() {
|
|||
{showModal && (
|
||||
<RegisterBusinessModal
|
||||
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 && (
|
||||
<DeleteConfirmModal
|
||||
businessName={getBusinessName(deleteTarget)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
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 { sendViaWorkflow } = require('../services/workflowSender');
|
||||
const { buildCrawlSummary } = require('../services/crawlSummary');
|
||||
const {
|
||||
uploadJSON,
|
||||
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 EVENT_TEMPLATE_FALLBACKS = {
|
||||
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) => {
|
||||
try {
|
||||
const merchantId = getCompanyId(req);
|
||||
|
|
@ -841,87 +1085,58 @@ router.post('/', async (req, res) => {
|
|||
{ code: 'MISSING_WEBSITE_URL' }
|
||||
);
|
||||
}
|
||||
const businesses = await getIndex(merchantId);
|
||||
|
||||
const businesses = await getIndex(merchantId);
|
||||
if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) {
|
||||
return res.status(409).json({ error: 'A business is already configured for this applicationId' });
|
||||
}
|
||||
|
||||
const businessId = uuidv4();
|
||||
const bizRoot = businessRoot(merchantId, businessId);
|
||||
const imagesFolder = `${bizRoot}/images`;
|
||||
const now = new Date().toISOString();
|
||||
const job = await saveOnboardingJob({
|
||||
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
|
||||
const scrapedData = await scrape(websiteUrl);
|
||||
res.status(202).json(buildJobResponse(job));
|
||||
} catch (err) {
|
||||
console.error('Start business onboarding error:', err.message);
|
||||
sendRouteError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Parse brand context
|
||||
const brandContext = await parseBrandContext(scrapedData);
|
||||
|
||||
// 3. Upload relevant images
|
||||
const imagePaths = [];
|
||||
for (let i = 0; i < Math.min((brandContext.relevantImageUrls || []).length, 5); i++) {
|
||||
const url = await uploadImageFromUrl(brandContext.relevantImageUrls[i], imagesFolder, `image_${i + 1}`);
|
||||
if (url) imagePaths.push(url);
|
||||
// GET /api/businesses/jobs/:jobId
|
||||
router.get('/jobs/:jobId', async (req, res) => {
|
||||
try {
|
||||
const companyId = getCompanyId(req);
|
||||
if (!companyId) {
|
||||
throw createHttpError(400, 'companyId is required');
|
||||
}
|
||||
|
||||
// 4. Build and upload context.json
|
||||
let domain = '';
|
||||
try { domain = new URL(websiteUrl).hostname; } catch { }
|
||||
const job = await loadOnboardingJobWithRetry(companyId, req.params.jobId);
|
||||
if (!job) {
|
||||
throw createHttpError(404, 'Onboarding job not found');
|
||||
}
|
||||
|
||||
const contextJson = {
|
||||
businessId,
|
||||
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,
|
||||
});
|
||||
const updatedJob = await advanceOnboardingJob(job);
|
||||
res.json(buildJobResponse(updatedJob));
|
||||
} catch (err) {
|
||||
console.error('Create business error:', err.message);
|
||||
sendRouteError(res, err);
|
||||
}
|
||||
});
|
||||
|
|
@ -930,9 +1145,23 @@ router.post('/', async (req, res) => {
|
|||
router.get('/:businessId', async (req, res) => {
|
||||
try {
|
||||
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' });
|
||||
res.json(context);
|
||||
|
||||
if (!crawlSummary) {
|
||||
return res.json(context);
|
||||
}
|
||||
|
||||
res.json({
|
||||
...context,
|
||||
scrapeArtifacts: buildScrapeArtifacts(crawlSummary, context.relevantImagePaths),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -1,44 +1,273 @@
|
|||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Scrape a website using Firecrawl.
|
||||
* Returns normalized fields plus the raw response payload for downstream UI/debug use.
|
||||
*/
|
||||
async function scrape(url) {
|
||||
const FIRECRAWL_BASE_URL = 'https://api.firecrawl.dev/v2';
|
||||
const DEFAULT_PRODUCT_PAGE_LIMIT = 5;
|
||||
const HOMEPAGE_FORMATS = ['markdown', 'links', 'images', 'branding'];
|
||||
const DISCOVERY_PAGE_FORMATS = ['markdown', 'links', 'images'];
|
||||
const CONTENT_PAGE_FORMATS = ['markdown', 'images'];
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
if (!apiKey) throw new Error('FIRECRAWL_API_KEY not set');
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'https://api.firecrawl.dev/v1/scrape',
|
||||
{ url, formats: ['markdown', 'links'] },
|
||||
{
|
||||
function getClient() {
|
||||
return axios.create({
|
||||
baseURL: FIRECRAWL_BASE_URL,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Authorization: `Bearer ${getApiKey()}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data?.data || response.data;
|
||||
const markdownLen = typeof data?.markdown === 'string' ? data.markdown.length : 0;
|
||||
const linksCount = Array.isArray(data?.links) ? data.links.length : 0;
|
||||
function normalizeDataEnvelope(data) {
|
||||
if (data && typeof data === 'object' && data.data && typeof data.data === 'object') {
|
||||
return data.data;
|
||||
}
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Firecrawl] scrape success | status=${response.status} | markdownLen=${markdownLen} | links=${linksCount}`
|
||||
);
|
||||
function normalizeText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeUrl(rawUrl, baseUrl = '') {
|
||||
const value = normalizeText(rawUrl);
|
||||
if (!value) return '';
|
||||
|
||||
try {
|
||||
const url = baseUrl ? new URL(value, baseUrl) : new URL(value);
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePageResult(page = {}, pageType = '') {
|
||||
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
|
||||
|
||||
return {
|
||||
markdown: typeof data?.markdown === 'string' ? data.markdown : '',
|
||||
links: Array.isArray(data?.links) ? data.links : [],
|
||||
metadata: data?.metadata && typeof data.metadata === 'object' ? data.metadata : {},
|
||||
images: Array.isArray(data?.images) ? data.images : [],
|
||||
json: data && typeof data === 'object' ? data : {},
|
||||
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 : {},
|
||||
};
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { scrape };
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,18 +50,47 @@ async function postWorkflow(url, payload) {
|
|||
}
|
||||
|
||||
async function parseBrandContext(scrapedData = {}) {
|
||||
const markdown = String(scrapedData.markdown || '').slice(0, 8000);
|
||||
const links = Array.isArray(scrapedData.links) ? scrapedData.links.slice(0, 200) : [];
|
||||
const representativePages = Array.isArray(scrapedData.representativePages)
|
||||
? 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 = {
|
||||
task: 'parse_brand_context',
|
||||
request_id: requestId('parse_brand_context'),
|
||||
markdown,
|
||||
links_json: JSON.stringify(links),
|
||||
metadata_json: JSON.stringify(scrapedData.metadata || {}),
|
||||
images_json: JSON.stringify(scrapedData.images || []),
|
||||
raw_json_blob: JSON.stringify(scrapedData.json || {}),
|
||||
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.',
|
||||
start_url: String(scrapedData.startUrl || ''),
|
||||
domain: String(scrapedData.domain || ''),
|
||||
site_stats_json: JSON.stringify(scrapedData.siteStats || {}),
|
||||
homepage_json: JSON.stringify(scrapedData.homepage || {}),
|
||||
about_page_json: JSON.stringify(scrapedData.aboutPage || {}),
|
||||
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',
|
||||
};
|
||||
|
||||
|
|
@ -76,6 +105,7 @@ async function parseBrandContext(scrapedData = {}) {
|
|||
taglines: Array.isArray(output.taglines) ? output.taglines.slice(0, 3).map(String) : [],
|
||||
colors: Array.isArray(output.colors) ? output.colors.map(String) : [],
|
||||
relevantImageUrls: Array.isArray(output.relevantImageUrls) ? output.relevantImageUrls.map(String) : [],
|
||||
aboutSummary: String(output.aboutSummary || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user