From 3ca86c53c6160f2615fc0dfc6316c436e7667166 Mon Sep 17 00:00:00 2001 From: Ritul Date: Wed, 1 Apr 2026 11:04:56 +0530 Subject: [PATCH] Major changes: UI, sales-channel API's application ID matching with pixelbin storage instead of brand name in payload --- client/src/App.jsx | 2 +- .../src/components/RegisterBusinessModal.jsx | 28 +- client/src/components/Sidebar.jsx | 8 +- client/src/components/TestSmsModal.jsx | 2 +- client/src/index.css | 34 + client/src/pages/Brand.jsx | 6 +- client/src/pages/Businesses.jsx | 340 ++++++-- client/src/pages/Events.jsx | 723 ++++++++++-------- client/src/pages/GlobalSms.jsx | 162 ++-- server/routes/businesses.js | 100 ++- server/services/firecrawl.js | 9 +- 11 files changed, 928 insertions(+), 486 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index b5757a8..55ab51a 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,7 +18,7 @@ function SubLayout({ children }) { {hasGlobalSms && ( diff --git a/client/src/components/RegisterBusinessModal.jsx b/client/src/components/RegisterBusinessModal.jsx index 151b9fc..e488935 100644 --- a/client/src/components/RegisterBusinessModal.jsx +++ b/client/src/components/RegisterBusinessModal.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import apiClient from '../api/client'; -export default function RegisterBusinessModal({ onClose }) { +export default function RegisterBusinessModal({ onClose, onSuccess }) { const [url, setUrl] = useState(''); const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); @@ -14,9 +14,15 @@ export default function RegisterBusinessModal({ onClose }) { setError(''); try { - await apiClient.post('/api/businesses', { + const res = await apiClient.post('/api/businesses', { websiteUrl: url.trim(), }); + + if (typeof onSuccess === 'function') { + await onSuccess(res.data); + return; + } + setStatus('success'); } catch (err) { setError(err.response?.data?.error || 'Something went wrong. Please try again.'); @@ -29,15 +35,21 @@ export default function RegisterBusinessModal({ onClose }) {
{status === 'success' && ( -
-
-

Business Added!

-

Your business has been registered successfully.

+
+

Business created

+

Storefront captured successfully

+

+ The business has been created and the scraped storefront details are ready for review. +

+
+

Website URL

+

{url}

+
)} @@ -74,7 +86,7 @@ export default function RegisterBusinessModal({ onClose }) { type="button" onClick={onClose} disabled={status === 'loading'} - className="flex-[0.8] py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition disabled:opacity-50" + className="flex-[0.8] py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" > Cancel diff --git a/client/src/components/Sidebar.jsx b/client/src/components/Sidebar.jsx index baea359..b4bf56c 100644 --- a/client/src/components/Sidebar.jsx +++ b/client/src/components/Sidebar.jsx @@ -49,7 +49,7 @@ function StageMarker({ done, active, enabled }) { } if (active) { - return ; + return ; } if (!enabled) { @@ -164,7 +164,7 @@ export default function Sidebar() { className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${ item.active ? 'bg-gray-100/70 text-gray-800' - : 'text-gray-500 hover:text-gray-800 hover:bg-white' + : 'text-gray-500 hover:text-gray-800 hover:bg-page-bg' }`} > {SVG_ICONS[item.id]} @@ -203,13 +203,13 @@ export default function Sidebar() { {item.substeps.map((substep) => (
- {substep.active &&
} + {substep.active &&
}
{substep.label} diff --git a/client/src/components/TestSmsModal.jsx b/client/src/components/TestSmsModal.jsx index 98aece9..1770b59 100644 --- a/client/src/components/TestSmsModal.jsx +++ b/client/src/components/TestSmsModal.jsx @@ -64,7 +64,7 @@ export default function TestSmsModal({ businessId, template, onClose }) { type="button" onClick={onClose} disabled={sending} - className="flex-1 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition disabled:opacity-50" + className="flex-1 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition disabled:opacity-50" > Cancel diff --git a/client/src/index.css b/client/src/index.css index 56acdf9..02544f4 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -53,6 +53,40 @@ body { -webkit-font-smoothing: antialiased; } +button, +[role="button"] { + transition: + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease, + box-shadow 160ms ease, + opacity 160ms ease, + transform 160ms ease; +} + +button:not(:disabled), +[role="button"]:not([aria-disabled="true"]) { + cursor: pointer; +} + +button:not(:disabled):hover, +[role="button"]:not([aria-disabled="true"]):hover { + box-shadow: 0 10px 22px -18px rgba(15, 23, 42, 0.35); +} + +button:disabled, +[role="button"][aria-disabled="true"] { + cursor: not-allowed; +} + +button:focus-visible, +[role="button"]:focus-visible { + outline: none; + box-shadow: + 0 0 0 3px rgba(56, 56, 196, 0.14), + 0 10px 22px -18px rgba(15, 23, 42, 0.35); +} + ::-webkit-scrollbar { width: 6px; } diff --git a/client/src/pages/Brand.jsx b/client/src/pages/Brand.jsx index d512498..cb09c97 100644 --- a/client/src/pages/Brand.jsx +++ b/client/src/pages/Brand.jsx @@ -25,7 +25,7 @@ function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) { @@ -116,7 +116,7 @@ export default function Brand() {
@@ -182,7 +182,7 @@ export default function Brand() {
{card.icon}

{card.label}

diff --git a/client/src/pages/Businesses.jsx b/client/src/pages/Businesses.jsx index 72e1664..13a3676 100644 --- a/client/src/pages/Businesses.jsx +++ b/client/src/pages/Businesses.jsx @@ -12,6 +12,110 @@ import { 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 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 ( +
+ {urls.map((url, index) => { + const Wrapper = clickable ? 'a' : 'div'; + const wrapperProps = clickable + ? { href: url, target: '_blank', rel: 'noreferrer' } + : {}; + + return ( + +
+ {`Storefront { + event.currentTarget.style.opacity = '0.35'; + }} + /> +
+ {showLabels && ( +
+

{url}

+
+ )} +
+ ); + })} +
+ ); +} + function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) { return (
@@ -27,7 +131,7 @@ function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) { @@ -49,35 +153,137 @@ function BusinessCreatedModal({ business, onClose }) { 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]); + const [viewportWidth, setViewportWidth] = useState(() => ( + typeof window === 'undefined' ? 1200 : window.innerWidth + )); + + useEffect(() => { + if (typeof window === 'undefined') return undefined; + + const handleResize = () => setViewportWidth(window.innerWidth); + handleResize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const preferredWidth = prettyJson + ? 1080 + : cdnUrls.length > 0 || links.length > 0 + ? 860 + : 560; + const modalWidth = Math.max(320, Math.min(viewportWidth - 32, preferredWidth)); return ( -
-
-
-

Business Added!

-

Your business is ready for onboarding.

-
-
-
- {image ? ( - {name} - ) : ( - {name?.[0]?.toUpperCase() || 'B'} - )} -
-
-

{name}

- {domain &&

{domain}

} - {tagline &&

{tagline}

} +
+
+
+
+

Business created

+

{name}

+

+ {domain + ? `Scrape completed for ${domain}. Review the captured assets below before moving on.` + : 'Scrape completed. Review the captured assets below before moving on.'} +

+
+ +
+ +
+
+
+
+ {image ? ( + {name} + ) : ( + {name?.[0]?.toUpperCase() || 'B'} + )} +
+
+

{name}

+ {domain &&

{domain}

} + {tagline &&

{tagline}

} +
+ + {cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'} + + + {links.length} link{links.length === 1 ? '' : 's'} + +
+
+ + {cdnUrls.length > 0 && ( +
+
+

Images

+

Captured storefront images are available below.

+
+ +
+ )} + + {prettyJson && ( +
+
+

Captured Data

+

Raw storefront data captured during onboarding.

+
+
+
+                  {prettyJson}
+                
+
+
+ )} + + {links.length > 0 && ( +
+
+

Links

+

Every discovered storefront link is available below.

+
+
+
+ {links.map((link, index) => ( + +

{link.label}

+

{link.href}

+
+ ))} +
+
+
+ )} +
+ +
+
-
); @@ -88,16 +294,14 @@ function StatusBadge({ status }) { return ( {isScraped ? 'Scraped' : 'Not Scraped Yet'} @@ -121,12 +325,35 @@ 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 hasWebsiteUrl = Boolean(item.channel?.websiteUrl); + const canOpenBusiness = isScraped && item.business && !isOpening; + + function handleCardClick() { + if (!canOpenBusiness) return; + onSelect(item.business); + } + + function handleCardKeyDown(event) { + if (!canOpenBusiness) return; + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(item.business); + } + } return ( -
+
@@ -163,21 +390,29 @@ function UnifiedBusinessCard({ A website URL could not be derived automatically for this sales channel.

)} + + {isScraped && cdnUrls.length > 0 && ( +
+
+

Images

+ + {cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'} + +
+ +
+ )}
{isScraped ? ( <> - @@ -320,6 +555,18 @@ export default function Businesses() { useEffect(() => { load(); }, [load]); + async function handleBusinessCreated(created) { + setShowModal(false); + setCreatedBusiness(created); + + try { + await Promise.all([loadBusinesses(), loadSalesChannels()]); + setSalesChannelsStatus('success'); + } catch (err) { + setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.'); + } + } + async function handleSelect(biz) { setSelectingBusinessId(biz.businessId); setError(''); @@ -350,9 +597,7 @@ export default function Businesses() { applicationId, websiteUrl: channel.websiteUrl, }); - setCreatedBusiness(res.data); - await Promise.all([loadBusinesses(), loadSalesChannels()]); - setSalesChannelsStatus('success'); + await handleBusinessCreated(res.data); } catch (err) { setError(err.response?.data?.error || 'Failed to add business from sales channel'); } finally { @@ -461,7 +706,7 @@ export default function Businesses() {

Use the manual fallback only if you need to set up a storefront URL directly.

@@ -501,7 +746,10 @@ export default function Businesses() {
{showModal && ( - { setShowModal(false); load(); }} /> + { setShowModal(false); load(); }} + onSuccess={handleBusinessCreated} + /> )} {createdBusiness && setCreatedBusiness(null)} />} {deleteTarget && ( diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index 9cc12a9..5eee825 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -56,13 +56,13 @@ const EVENT_GROUPS = [ id: 'fulfillment', label: 'Order & Fulfillment', description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.', - defaultExpanded: true, + defaultExpanded: false, }, { id: 'delivery', label: 'Delivery Journey', description: 'Courier pickup, in-transit updates, and final handover milestones.', - defaultExpanded: true, + defaultExpanded: false, }, { id: 'cancellations', @@ -101,22 +101,73 @@ const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => { }, {}); const EVENT_TEMPLATE_STATUS_CONFIG = { unselected: { - label: 'No template selected', - wrapper: 'border-gray-200 bg-white text-gray-500', + label: 'Not Selected', + badge: 'border-gray-200 bg-white text-gray-500', dot: 'bg-gray-400', }, pending_whitelisting: { label: 'Pending Whitelisting', - wrapper: 'border-gray-200 bg-white text-gray-700', - dot: 'bg-white0', + badge: 'border-amber-200 bg-amber-50 text-amber-700', + dot: 'bg-amber-500', }, whitelisted: { label: 'Published', - wrapper: 'border-gray-200 bg-white text-gray-700', - dot: 'bg-white0', + badge: 'border-emerald-200 bg-emerald-50 text-emerald-700', + dot: 'bg-emerald-500', }, }; +const EVENT_GROUP_STYLE_CONFIG = { + fulfillment: { + markerShell: 'border-slate-200 bg-slate-50', + markerDot: 'bg-slate-500', + }, + delivery: { + markerShell: 'border-sky-200 bg-sky-50', + markerDot: 'bg-sky-500', + }, + cancellations: { + markerShell: 'border-rose-200 bg-rose-50', + markerDot: 'bg-rose-500', + }, + returns: { + markerShell: 'border-indigo-200 bg-indigo-50', + markerDot: 'bg-indigo-500', + }, + refunds: { + markerShell: 'border-emerald-200 bg-emerald-50', + markerDot: 'bg-emerald-500', + }, + rto: { + markerShell: 'border-fuchsia-200 bg-fuchsia-50', + markerDot: 'bg-fuchsia-500', + }, + custom: { + markerShell: 'border-indigo-200 bg-indigo-50', + markerDot: 'bg-indigo-500', + }, +}; + +function normalizeTemplateStatus(status) { + return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting'; +} + +function buildSelectedTemplatePreview(template = {}) { + const selectedTemplate = String(template?.selectedTemplate || '').trim(); + if (!selectedTemplate) return null; + + return { + eventSlug: String(template?.eventSlug || '').trim(), + selectedTemplate, + status: normalizeTemplateStatus(template?.status), + templateId: String(template?.templateId || '').trim(), + variableMap: template?.variableMap && typeof template.variableMap === 'object' + ? template.variableMap + : {}, + curlProfileId: String(template?.curlProfileId || '').trim(), + }; +} + function getEventGroupId(event) { const slug = String(event?.slug || ''); @@ -212,17 +263,16 @@ function buildTemplateUiState(templates = []) { const nextVariants = {}; const nextGenState = {}; const nextTemplateStatusBySlug = {}; + const nextSelectedTemplateBySlug = {}; templates.forEach((template) => { if (!template?.eventSlug) return; if (template.selectedTemplate) { - if (template.status === 'whitelisted') { - nextTemplateStatusBySlug[template.eventSlug] = 'whitelisted'; - } else { - nextTemplateStatusBySlug[template.eventSlug] = 'pending_whitelisting'; - } + const normalizedStatus = normalizeTemplateStatus(template.status); + nextTemplateStatusBySlug[template.eventSlug] = normalizedStatus; nextGenState[template.eventSlug] = 'selected'; + nextSelectedTemplateBySlug[template.eventSlug] = buildSelectedTemplatePreview(template); return; } @@ -232,7 +282,7 @@ function buildTemplateUiState(templates = []) { } }); - return { nextVariants, nextGenState, nextTemplateStatusBySlug }; + return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug }; } export default function Events() { @@ -253,6 +303,7 @@ export default function Events() { const [openVariableMenuKey, setOpenVariableMenuKey] = useState(''); const [activeCaretVariantKey, setActiveCaretVariantKey] = useState(''); const [templateStatusBySlug, setTemplateStatusBySlug] = useState({}); + const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({}); const [error, setError] = useState(''); const [readyToGenerate, setReadyToGenerate] = useState(false); @@ -283,13 +334,19 @@ export default function Events() { ]); const templates = templatesRes.data.templates || []; - const { nextVariants, nextGenState, nextTemplateStatusBySlug } = buildTemplateUiState(templates); + const { + nextVariants, + nextGenState, + nextTemplateStatusBySlug, + nextSelectedTemplateBySlug, + } = buildTemplateUiState(templates); setEvents(eventsRes.data.events || []); setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl); setVariants(nextVariants); setGenState(nextGenState); setTemplateStatusBySlug(nextTemplateStatusBySlug); + setSelectedTemplateBySlug(nextSelectedTemplateBySlug); setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants)); } catch { setError('Failed to load events'); @@ -353,6 +410,11 @@ export default function Events() { delete nextStatuses[slug]; return nextStatuses; }); + setSelectedTemplateBySlug((currentTemplates) => { + const nextTemplates = { ...currentTemplates }; + delete nextTemplates[slug]; + return nextTemplates; + }); setGenState((state) => ({ ...state, [slug]: 'done' })); } catch (err) { setError(err.response?.data?.error || 'Generation failed'); @@ -413,7 +475,8 @@ export default function Events() { setError(''); try { - await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); + const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); + const selectedTemplatePreview = buildSelectedTemplatePreview(res.data); await refreshOnboardingState(businessId).catch(() => null); setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] })); setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug)); @@ -421,6 +484,10 @@ export default function Events() { setActiveCaretVariantKey(''); setGenState((state) => ({ ...state, [slug]: 'selected' })); setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' })); + setSelectedTemplateBySlug((currentTemplates) => ({ + ...currentTemplates, + [slug]: selectedTemplatePreview, + })); if (shouldAutoAdvance) { navigate(`/${businessId}/templates?event=${encodeURIComponent(slug)}`); } @@ -555,7 +622,7 @@ export default function Events() { @@ -605,37 +672,31 @@ export default function Events() {
{groupedEvents.map((group) => { const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id]; + const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom; return (
- )} -
-

{event.label}

-
-
- -
- - - - {canViewTemplate && ( - - )} - -
-
- - {eventVariants.length > 0 && ( -
-

Review, edit, and choose a variant

-
- {eventVariants.map((variant, index) => { - const variantKey = getVariantKey(event.slug, index); - const draft = variantDrafts[variantKey] || createVariantDraft(variant); - const currentText = draft.currentText; - const originalText = draft.originalText; - const validationStatus = draft.validationStatus; - const currentMatchesCheckedText = draft.lastCheckedText === currentText; - const isEdited = currentText !== originalText; - const dltTokenCount = countDltTokens(currentText); - const invalidDltTokens = getInvalidDltTokens(currentText); - const hasMalformedDltToken = hasMalformedDltFragments(currentText); - const hasInvalidPlaceholder = invalidDltTokens.length > 0 || hasMalformedDltToken; - const tooLong = currentText.length > MAX_SMS_LENGTH; - const isSelectingThis = selectingVariantKey === variantKey; - const isSelectingAnotherVariant = !!selectingVariantKey - && selectingVariantKey !== variantKey - && selectingVariantKey.startsWith(`${event.slug}:`); - const canRunCheck = isEdited && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking'; - const canUseEdited = isEdited - && validationStatus === 'approved' - && currentMatchesCheckedText - && !tooLong - && !hasInvalidPlaceholder; - const canInsertVariable = activeCaretVariantKey === variantKey; - - return ( -
-
-
- - {isEdited ? 'Edited Draft' : 'Original Draft'} - - - {validationStatus === 'checking' && ( - - Checking edit… - - )} - - {validationStatus === 'approved' && currentMatchesCheckedText && ( - - Edit passed check - - )} - - {validationStatus === 'rejected' && currentMatchesCheckedText && ( - - Needs changes - - )} -
- -
{ - if (node) variableMenuRefs.current[variantKey] = node; - else delete variableMenuRefs.current[variantKey]; - }} - > - - - {openVariableMenuKey === variantKey && ( -
-
-

Insert DLT Variable

+ return ( +
+
+
+ {event.isDefault ? ( +
+
-
- {DLT_VARIABLE_OPTIONS.map((option) => ( - - ))} -
-
- )} -
-
- -