Major changes: UI, sales-channel API's application ID matching with pixelbin storage instead of brand name in payload

This commit is contained in:
Ritul Jadhav 2026-04-01 11:04:56 +05:30
parent c6fee788f0
commit 3ca86c53c6
11 changed files with 928 additions and 486 deletions

View File

@ -18,7 +18,7 @@ function SubLayout({ children }) {
{hasGlobalSms && (
<Link
to={`/${activeBusinessId}/settings`}
className="w-10 h-10 rounded-full hover:bg-white text-gray-500 hover:text-primary-blue flex items-center justify-center transition-colors border border-border-soft"
className="flex h-10 w-10 items-center justify-center rounded-full border border-border-soft bg-gray-50 text-gray-500 transition-colors hover:border-gray-300 hover:bg-white hover:text-primary-blue"
title="Settings"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>

View File

@ -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 }) {
<div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
{status === 'success' && (
<div className="text-center">
<div className="w-14 h-14 rounded-full bg-white text-green-600 flex items-center justify-center mx-auto mb-4 text-2xl"></div>
<h2 className="text-xl font-bold text-gray-800 mb-2">Business Added!</h2>
<p className="text-gray-500 text-sm mb-6 font-medium">Your business has been registered successfully.</p>
<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>
<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.
</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>
<p className="text-sm text-gray-700 break-all">{url}</p>
</div>
<button
onClick={onClose}
className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
>
Done
Next
</button>
</div>
)}
@ -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
</button>

View File

@ -49,7 +49,7 @@ function StageMarker({ done, active, enabled }) {
}
if (active) {
return <span className="inline-block h-3 w-3 rounded-full border-2 border-white bg-primary-blue -[0_0_0_1px_var(--color-primary-blue)]" />;
return <span className="inline-block h-3 w-3 rounded-full border-2 border-white bg-primary-blue shadow-[0_0_0_1px_var(--color-primary-blue)]" />;
}
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) => (
<div key={substep.id} className="relative flex items-center pr-3 group cursor-default">
<div className="w-[44px] flex justify-center items-center shrink-0">
{substep.active && <div className="w-1.5 h-1.5 rounded-full bg-primary-blue z-10 -[0_0_0_2px_white]" />}
{substep.active && <div className="z-10 h-1.5 w-1.5 rounded-full bg-primary-blue shadow-[0_0_0_2px_white]" />}
</div>
<div
className={`flex-1 px-3 py-2 rounded-md text-[14px] transition-colors ${
substep.active
? 'bg-white text-primary-blue font-semibold'
: 'text-gray-500 font-medium hover:text-gray-800 hover:bg-white'
: 'text-gray-500 font-medium hover:text-gray-800 hover:bg-page-bg'
}`}
>
{substep.label}

View File

@ -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
</button>

View File

@ -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;
}

View File

@ -25,7 +25,7 @@ function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) {
<button
onClick={onCancel}
disabled={deleting}
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
</button>
@ -116,7 +116,7 @@ export default function Brand() {
</div>
<button
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0 text-xs text-red-600 hover:text-gray-700 hover:bg-white border border-gray-200 px-3 py-2 rounded-lg font-medium transition"
className="shrink-0 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-red-600 transition hover:border-red-200 hover:bg-red-50 hover:text-red-700"
>
Delete Brand
</button>
@ -182,7 +182,7 @@ export default function Brand() {
<Link
key={card.to}
to={card.to}
className="rounded-lg bg-white border border-gray-200 p-5 hover:border-primary-blue hover: hover:-blue transition-all group"
className="group rounded-lg bg-white border border-gray-200 p-5 transition-all hover:border-primary-blue hover:shadow-sm"
>
<div className="text-2xl mb-3 grayscale group-hover:grayscale-0 opacity-80 group-hover:opacity-100 transition-all">{card.icon}</div>
<p className="font-semibold text-gray-800 mb-1">{card.label}</p>

View File

@ -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 (
<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">
@ -27,7 +131,7 @@ function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
<button
onClick={onCancel}
disabled={deleting}
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
</button>
@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
<div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
<div className="w-14 h-14 rounded-full bg-white text-green-600 flex items-center justify-center mx-auto mb-4 text-2xl"></div>
<h2 className="text-xl font-bold text-gray-800 mb-2 text-center">Business Added!</h2>
<p className="text-gray-500 text-sm mb-4 font-medium text-center">Your business is ready for onboarding.</p>
<div className="rounded-lg border border-gray-200 bg-white p-4 mb-6">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg 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" />
) : (
<span className="text-xl font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'B'}</span>
)}
</div>
<div className="min-w-0">
<p className="text-primary-blue font-bold text-lg tracking-tight truncate">{name}</p>
{domain && <p className="text-sm text-gray-500 font-medium truncate mt-0.5">{domain}</p>}
{tagline && <p className="text-sm text-gray-700 mt-2 leading-relaxed line-clamp-2">{tagline}</p>}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div
className="w-full overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl"
style={{ width: `${modalWidth}px`, maxWidth: 'calc(100vw - 2rem)', maxHeight: '88vh' }}
>
<div className="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>
<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.'}
</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="overflow-y-auto px-6 py-5 space-y-6">
<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" />
) : (
<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} />
</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-80 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
{prettyJson}
</pre>
</div>
</section>
)}
{links.length > 0 && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Links</p>
<p className="mt-1 text-sm text-gray-500">Every discovered storefront link is available below.</p>
</div>
<div className="rounded-xl border border-gray-200">
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
{links.map((link, index) => (
<a
key={`${link.href}-${index}`}
href={link.href}
target="_blank"
rel="noreferrer"
className="block px-4 py-3 transition hover:bg-gray-50"
>
<p className="text-sm font-medium text-gray-800 break-all">{link.label}</p>
<p className="mt-1 text-xs text-primary-blue break-all">{link.href}</p>
</a>
))}
</div>
</div>
</section>
)}
</div>
<div className="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>
<button
onClick={onClose}
className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
>
Done
</button>
</div>
</div>
);
@ -88,16 +294,14 @@ function StatusBadge({ status }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${
isScraped
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${isScraped
? 'bg-green-100 text-green-700'
: 'bg-amber-100 text-amber-700'
}`}
}`}
>
<span
className={`h-2 w-2 rounded-full ${
isScraped ? 'bg-green-500' : 'bg-amber-500'
}`}
className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
}`}
/>
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
</span>
@ -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 (
<div className="group rounded-lg bg-white border border-gray-200 hover:border-primary-blue transition-all overflow-hidden">
<div
className={`group rounded-lg bg-white border border-gray-200 transition-all overflow-hidden ${isScraped ? 'cursor-pointer hover:border-primary-blue hover:shadow-sm' : 'hover:border-primary-blue'}`}
onClick={handleCardClick}
onKeyDown={handleCardKeyDown}
role={isScraped ? 'button' : undefined}
tabIndex={isScraped ? 0 : undefined}
aria-label={isScraped ? `Open ${name}` : undefined}
>
<div className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-4 min-w-0">
@ -163,21 +390,29 @@ function UnifiedBusinessCard({
A website URL could not be derived automatically for this sales channel.
</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">
{isScraped ? (
<>
<button
className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
onClick={() => onSelect(item.business)}
disabled={isOpening}
>
{isOpening ? 'Opening…' : 'Manage →'}
</button>
<button
onClick={() => onDelete(item.business)}
className="text-xs text-gray-600 hover:text-gray-700 font-medium transition"
onClick={(event) => {
event.stopPropagation();
onDelete(item.business);
}}
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-600 transition hover:bg-red-50 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-200"
>
Delete
</button>
@ -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() {
<p className="text-sm text-gray-500 mb-4">Use the manual fallback only if you need to set up a storefront URL directly.</p>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-white text-sm font-medium transition"
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition"
>
Use website URL fallback
</button>
@ -501,7 +746,10 @@ export default function Businesses() {
</div>
{showModal && (
<RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />
<RegisterBusinessModal
onClose={() => { setShowModal(false); load(); }}
onSuccess={handleBusinessCreated}
/>
)}
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
{deleteTarget && (

View File

@ -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() {
</span>
<button
onClick={() => setShowAddForm((visible) => !visible)}
className="px-4 py-2 rounded-lg bg-white border border-gray-300 text-sm text-gray-700 font-semibold hover:bg-white transition"
className="px-4 py-2 rounded-lg bg-white border border-gray-300 text-sm text-gray-700 font-semibold hover:bg-gray-50 hover:border-gray-400 transition"
>
{showAddForm ? 'Cancel' : '+ Add Event'}
</button>
@ -605,37 +672,31 @@ export default function Events() {
<div className="space-y-4">
{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 (
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
<button
type="button"
onClick={() => toggleGroup(group.id)}
className="flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-white"
className="group flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50"
>
<div className="flex min-w-0 items-start gap-4">
<div className={`mt-1 h-3 w-3 rounded-full ${
group.id === 'fulfillment' ? 'bg-gray-400' :
group.id === 'delivery' ? 'bg-sky-500' :
group.id === 'cancellations' ? 'bg-rose-500' :
group.id === 'returns' ? 'bg-white0' :
group.id === 'refunds' ? 'bg-emerald-500' :
group.id === 'rto' ? 'bg-fuchsia-500' :
'bg-white0'
}`} />
<div className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${groupStyle.markerShell}`}>
<span className={`h-2.5 w-2.5 rounded-full ${groupStyle.markerDot}`} />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
{group.events.length} events
</span>
</div>
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
</div>
</div>
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition ${
isExpanded ? 'rotate-180' : ''
}`}>
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 text-gray-600 shadow-sm transition group-hover:border-gray-300 group-hover:bg-white group-hover:text-gray-800 ${isExpanded ? 'rotate-180' : ''
}`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
</svg>
@ -645,307 +706,323 @@ export default function Events() {
{isExpanded && (
<div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
<div className="space-y-4">
{group.events.map((event) => {
const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || [];
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
const canViewTemplate = templateStatus !== 'unselected';
{group.events.map((event) => {
const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || [];
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
const canViewTemplate = templateStatus !== 'unselected';
return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4">
<div className="flex items-start gap-4">
{event.isDefault ? (
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
) : (
<button
onClick={() => handleDelete(event.slug)}
className="mt-0.5 w-6 h-6 rounded-full bg-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 transition shrink-0"
title="Delete event"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
)}
<div>
<h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
</div>
</div>
<div className="flex items-center gap-3">
<span
title={statusConfig.label}
aria-label={statusConfig.label}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border ${statusConfig.wrapper}`}
>
<span className={`h-2.5 w-2.5 rounded-full ${statusConfig.dot}`} />
</span>
{canViewTemplate && (
<button
type="button"
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-white transition "
>
View in Templates
</button>
)}
<button
onClick={() => handleGenerate(event.slug)}
disabled={state === 'loading' || !readyToGenerate}
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${
state === 'done' || state === 'selected'
? 'bg-white border border-gray-300 text-gray-700 hover:bg-white'
: 'bg-white border border-gray-200 text-primary-dark hover:bg-indigo-100'
}`}
>
{state === 'loading' ? (
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating</>
) : state === 'done' || state === 'selected' ? (
<> Regenerate</>
) : (
<> Generate Template</>
)}
</button>
</div>
</div>
{eventVariants.length > 0 && (
<div className="border-t border-gray-100 bg-white px-6 py-5 space-y-4">
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p>
<div className="grid gap-4">
{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 (
<div
key={variantKey}
className={`rounded-lg border bg-white p-5 transition ${
isSelectingThis
? 'border-primary-blue '
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
isEdited
? 'bg-white border-gray-200 text-gray-700'
: 'bg-white border-gray-200 text-gray-600'
}`}>
{isEdited ? 'Edited Draft' : 'Original Draft'}
</span>
{validationStatus === 'checking' && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Checking edit
</span>
)}
{validationStatus === 'approved' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Edit passed check
</span>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Needs changes
</span>
)}
</div>
<div
className="relative"
ref={(node) => {
if (node) variableMenuRefs.current[variantKey] = node;
else delete variableMenuRefs.current[variantKey];
}}
>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleVariableMenuToggle(variantKey)}
disabled={!canInsertVariable}
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-200 text-primary-dark font-semibold hover:bg-white transition disabled:opacity-50 disabled:cursor-not-allowed"
>
# Add Variable
</button>
{openVariableMenuKey === variantKey && (
<div className="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-2 border-b border-gray-100 bg-white">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">Insert DLT Variable</p>
return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
{event.isDefault ? (
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
<div className="py-1">
{DLT_VARIABLE_OPTIONS.map((option) => (
<button
key={option.token}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertVariableToken(event.slug, index, option.token)}
className="w-full px-4 py-2 text-left hover:bg-white transition flex items-center justify-between gap-3"
>
<span className="text-sm font-semibold text-gray-800">{option.label}</span>
<span className="text-xs font-mono text-primary-dark">{option.token}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<textarea
ref={(node) => {
if (node) textareaRefs.current[variantKey] = node;
else delete textareaRefs.current[variantKey];
}}
value={currentText}
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
onFocus={(e) => trackTextareaSelection(variantKey, e.target)}
onClick={(e) => trackTextareaSelection(variantKey, e.target)}
onSelect={(e) => trackTextareaSelection(variantKey, e.target)}
onKeyUp={(e) => trackTextareaSelection(variantKey, e.target)}
rows={4}
className={`w-full rounded-lg border px-4 py-2 text-sm text-gray-800 font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 ${
isEdited
? 'border-gray-200 bg-white/40 focus:ring-amber-200 focus:border-amber-300'
: 'border-gray-200 bg-white focus:ring-indigo-100 focus:border-primary-blue'
}`}
/>
<div className="flex flex-wrap items-center justify-between gap-3 mt-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
tooLong
? 'bg-white border-gray-200 text-gray-700'
: 'bg-gray-100 border-gray-200 text-gray-600'
}`}>
{currentText.length} / {MAX_SMS_LENGTH}
</span>
<span className="text-xs font-semibold px-2.5 py-1 rounded-md border bg-white border-gray-200 text-primary-dark">
DLT vars: {dltTokenCount}
</span>
</div>
{isEdited && (
<button
onClick={() => handleRevertVariant(event.slug, index)}
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-white transition"
>
Revert to original
</button>
)}
</div>
{isEdited && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p>
<p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
</div>
)}
{invalidDltTokens.length > 0 && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-amber-800">
Unsupported DLT variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>.
Use only {DLT_VARIABLE_OPTIONS.map((option) => option.token).join(', ')}.
</div>
)}
{hasMalformedDltToken && invalidDltTokens.length === 0 && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-amber-800">
Finish or remove incomplete DLT placeholder text before checking or selecting this edit.
</div>
)}
{tooLong && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700">
Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version.
</div>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700">
<span className="font-semibold">Why it did not pass:</span> {draft.why}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-4">
{!isEdited ? (
<button
onClick={() => handleSelect(event.slug, currentText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-primary-blue hover:bg-primary-dark text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use this template'}
</button>
) : (
<>
<button
onClick={() => handleSelect(event.slug, originalText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-white transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use original'}
</button>
{canUseEdited ? (
<button
onClick={() => handleSelect(event.slug, currentText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-green-600 hover:bg-green-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use edited version'}
</button>
) : (
<button
onClick={() => handleValidateEdit(event.slug, index)}
disabled={!canRunCheck || isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-primary-blue hover:bg-primary-dark text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
onClick={() => handleDelete(event.slug)}
className="mt-0.5 w-6 h-6 rounded-full bg-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 transition shrink-0"
title="Delete event"
>
{validationStatus === 'checking'
? 'Checking…'
: validationStatus === 'rejected' && currentMatchesCheckedText
? 'Check again'
: 'Check edit'}
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
)}
</>
<div>
<h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
{selectedTemplatePreview && (
<div className="mt-3 max-w-2xl rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Selected Template</p>
<p className="mt-2 text-sm leading-relaxed text-gray-700 line-clamp-3 break-words">
{selectedTemplatePreview.selectedTemplate}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{selectedTemplatePreview.templateId ? (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
Template ID {selectedTemplatePreview.templateId}
</span>
) : (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-500">
Template ID pending
</span>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<span
title={statusConfig.label}
aria-label={statusConfig.label}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
>
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
{statusConfig.label}
</span>
{canViewTemplate && (
<button
type="button"
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition "
>
View in Templates
</button>
)}
<button
onClick={() => handleGenerate(event.slug)}
disabled={state === 'loading' || !readyToGenerate}
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${state === 'done' || state === 'selected'
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400'
: 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50'
}`}
>
{state === 'loading' ? (
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating</>
) : state === 'done' || state === 'selected' ? (
<> Regenerate</>
) : (
<> Generate Template</>
)}
</button>
</div>
</div>
{eventVariants.length > 0 && (
<div className="border-t border-gray-100 bg-white px-6 py-5 space-y-4">
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p>
<div className="grid gap-4">
{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 (
<div
key={variantKey}
className={`rounded-lg border bg-white p-5 transition ${isSelectingThis
? 'border-primary-blue '
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${isEdited
? 'bg-white border-gray-200 text-gray-700'
: 'bg-white border-gray-200 text-gray-600'
}`}>
{isEdited ? 'Edited Draft' : 'Original Draft'}
</span>
{validationStatus === 'checking' && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Checking edit
</span>
)}
{validationStatus === 'approved' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Edit passed check
</span>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-white border-gray-200 text-gray-700">
Needs changes
</span>
)}
</div>
<div
className="relative"
ref={(node) => {
if (node) variableMenuRefs.current[variantKey] = node;
else delete variableMenuRefs.current[variantKey];
}}
>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleVariableMenuToggle(variantKey)}
disabled={!canInsertVariable}
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-200 text-primary-dark font-semibold hover:bg-gray-50 hover:border-gray-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
# Add Variable
</button>
{openVariableMenuKey === variantKey && (
<div className="absolute right-0 z-20 mt-2 w-56 rounded-lg border border-gray-200 bg-white overflow-hidden">
<div className="px-4 py-2 border-b border-gray-100 bg-white">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">Insert DLT Variable</p>
</div>
<div className="py-1">
{DLT_VARIABLE_OPTIONS.map((option) => (
<button
key={option.token}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertVariableToken(event.slug, index, option.token)}
className="flex w-full items-center justify-between gap-3 px-4 py-2 text-left transition hover:bg-gray-50"
>
<span className="text-sm font-semibold text-gray-800">{option.label}</span>
<span className="text-xs font-mono text-primary-dark">{option.token}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<textarea
ref={(node) => {
if (node) textareaRefs.current[variantKey] = node;
else delete textareaRefs.current[variantKey];
}}
value={currentText}
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
onFocus={(e) => trackTextareaSelection(variantKey, e.target)}
onClick={(e) => trackTextareaSelection(variantKey, e.target)}
onSelect={(e) => trackTextareaSelection(variantKey, e.target)}
onKeyUp={(e) => trackTextareaSelection(variantKey, e.target)}
rows={4}
className={`w-full rounded-lg border px-4 py-2 text-sm text-gray-800 font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 ${isEdited
? 'border-gray-200 bg-white/40 focus:ring-amber-200 focus:border-amber-300'
: 'border-gray-200 bg-white focus:ring-indigo-100 focus:border-primary-blue'
}`}
/>
<div className="flex flex-wrap items-center justify-between gap-3 mt-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${tooLong
? 'bg-white border-gray-200 text-gray-700'
: 'bg-gray-100 border-gray-200 text-gray-600'
}`}>
{currentText.length} / {MAX_SMS_LENGTH}
</span>
<span className="text-xs font-semibold px-2.5 py-1 rounded-md border bg-white border-gray-200 text-primary-dark">
DLT vars: {dltTokenCount}
</span>
</div>
{isEdited && (
<button
onClick={() => handleRevertVariant(event.slug, index)}
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50 hover:border-gray-400 transition"
>
Revert to original
</button>
)}
</div>
{isEdited && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p>
<p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
</div>
)}
{invalidDltTokens.length > 0 && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-amber-800">
Unsupported DLT variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>.
Use only {DLT_VARIABLE_OPTIONS.map((option) => option.token).join(', ')}.
</div>
)}
{hasMalformedDltToken && invalidDltTokens.length === 0 && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-amber-800">
Finish or remove incomplete DLT placeholder text before checking or selecting this edit.
</div>
)}
{tooLong && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700">
Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version.
</div>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && (
<div className="mt-3 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700">
<span className="font-semibold">Why it did not pass:</span> {draft.why}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-4">
{!isEdited ? (
<button
onClick={() => handleSelect(event.slug, currentText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-primary-blue hover:bg-primary-dark text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use this template'}
</button>
) : (
<>
<button
onClick={() => handleSelect(event.slug, originalText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-gray-50 hover:border-gray-400 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use original'}
</button>
{canUseEdited ? (
<button
onClick={() => handleSelect(event.slug, currentText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-green-600 hover:bg-green-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use edited version'}
</button>
) : (
<button
onClick={() => handleValidateEdit(event.slug, index)}
disabled={!canRunCheck || isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-primary-blue hover:bg-primary-dark text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{validationStatus === 'checking'
? 'Checking…'
: validationStatus === 'rejected' && currentMatchesCheckedText
? 'Check again'
: 'Check edit'}
</button>
)}
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
);
})}
</div>
</div>
)}

View File

@ -65,6 +65,8 @@ export default function GlobalSms() {
}, [loadProfiles]);
const activeProfile = profiles.find(p => p.id === activeProfileId) || null;
const hasProfiles = profiles.length > 0;
const isCreatingFirstProfile = !hasProfiles && !editingId;
const pData = activeProfile?.provider || {};
const missingFields = [];
if (activeProfile && !pData.providerName) missingFields.push('providerName');
@ -303,104 +305,140 @@ export default function GlobalSms() {
</div>
)}
{/* Profiles List */}
<div className="space-y-4 pt-4 border-t border-border-soft">
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3>
{profiles.length > 0 ? (
profiles.map(p => {
{hasProfiles && (
<div className="space-y-4 pt-4 border-t border-border-soft">
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3>
{profiles.map(p => {
const isActive = p.id === activeProfileId;
return (
<div key={p.id} className={`p-5 rounded-lg border ${isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'} flex flex-col md:flex-row gap-4 items-start md:items-center justify-between transition-colors`}>
<div className="flex-1 overflow-hidden">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-bold text-text-primary text-base truncate">{p.name}</h3>
{isActive && (
<span className="px-2 py-0.5 rounded text-[11px] font-bold tracking-wider uppercase bg-white text-gray-700 border border-gray-200 shrink-0">
Active Profile
</span>
<div
key={p.id}
className={`rounded-xl border p-5 transition-colors ${
isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'
}`}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h3 className="truncate text-base font-bold text-text-primary">{p.name}</h3>
{isActive && (
<span className="shrink-0 rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-primary-dark">
Active Profile
</span>
)}
{p.isDefault && !isActive && (
<span className="shrink-0 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-gray-600">
Default
</span>
)}
</div>
<p className="mb-3 text-xs font-medium text-text-muted">
Updated: {new Date(p.updatedAt).toLocaleString()}
</p>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
<div className="border-b border-gray-800 px-4 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Raw cURL</p>
</div>
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
<code>{p.rawCurl}</code>
</pre>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
{!isActive && (
<button
onClick={() => handleActivate(p.id)}
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
>
Use this cURL
</button>
)}
{p.isDefault && !isActive && (
<span className="px-2 py-0.5 rounded text-[11px] font-bold tracking-wider uppercase bg-white text-gray-700 border border-gray-200 shrink-0">
Default
</span>
<button
onClick={() => handleEditClick(p)}
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-primary-blue hover:bg-page-bg hover:text-primary-blue"
>
Edit
</button>
{profiles.length > 1 && (
<button
onClick={() => handleDelete(p.id)}
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-error-text hover:bg-red-50 hover:text-error-text"
>
Delete
</button>
)}
</div>
<p className="text-xs text-text-muted font-medium mb-2">Updated: {new Date(p.updatedAt).toLocaleString()}</p>
<p className="text-sm font-mono text-text-muted bg-page-bg p-2 rounded border border-border-soft overflow-hidden whitespace-nowrap text-ellipsis max-w-xl">
{p.rawCurl}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{!isActive && (
<button
onClick={() => handleActivate(p.id)}
className="px-4 py-2 bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold rounded-lg transition"
>
Use this cURL
</button>
)}
<button
onClick={() => handleEditClick(p)}
className="px-3 py-2 border border-border-main text-text-muted hover:text-primary-blue hover:border-primary-blue rounded-lg text-sm font-medium transition"
>
Edit
</button>
{profiles.length > 1 && (
<button
onClick={() => handleDelete(p.id)}
className="px-3 py-2 border border-border-main text-text-muted hover:text-error-text hover:border-error-text hover:bg-white rounded-lg text-sm font-medium transition"
>
Delete
</button>
)}
</div>
</div>
);
})
) : (
<div className="text-center py-12 bg-surface-white border border-border-main border-dashed rounded-lg">
<p className="text-sm font-medium text-text-muted mb-4">No cURL profiles configured yet.</p>
</div>
)}
</div>
})}
</div>
)}
{/* Inline Form (Create / Edit) */}
<div className="bg-surface-white border border-border-main rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border-main bg-table-header flex items-center justify-between">
<h3 className="font-bold text-text-primary text-md">
{editingId ? 'Edit Profile' : 'Add New Profile'}
</h3>
<div
className={`overflow-hidden rounded-xl border ${
isCreatingFirstProfile ? 'border-indigo-100 bg-white shadow-sm' : 'border-border-main bg-surface-white'
}`}
>
<div
className={`flex items-start justify-between gap-4 px-6 py-5 ${
isCreatingFirstProfile ? 'border-b border-indigo-100 bg-indigo-50/60' : 'border-b border-border-main bg-table-header'
}`}
>
<div>
<h3 className="font-bold text-text-primary text-md">
{editingId ? 'Edit Profile' : isCreatingFirstProfile ? 'Create Your First cURL Profile' : 'Add New Profile'}
</h3>
<p className="mt-1 text-sm text-text-muted">
Give this profile a recognizable name, then paste the full provider cURL command below.
</p>
</div>
{editingId && (
<button onClick={handleAddClick} className="text-sm font-semibold text-primary-blue hover:underline">
<button onClick={handleAddClick} className="text-sm font-semibold text-primary-blue hover:text-primary-dark hover:underline">
Switch to Add New
</button>
)}
</div>
<div className="p-5">
<div className="p-6">
{isCreatingFirstProfile && (
<div className="mb-6 rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-4">
<p className="text-sm font-semibold text-text-primary">Start by adding a cURL profile</p>
<p className="mt-1 text-sm text-text-muted">
This becomes the base for validating provider details and unlocking event template generation.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label>
<p className="mb-2 text-xs font-medium text-text-muted">
Use a name you will recognize later, such as `Production SMS` or `Backup Provider`.
</p>
<input
type="text"
value={formName}
onChange={e => setFormName(e.target.value)}
placeholder="e.g. Production SMS, Staging Twilio"
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue transition text-sm"
className="w-full rounded-lg border border-border-main bg-white px-4 py-2 text-sm text-text-primary placeholder-placeholder-bg transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
required
/>
</div>
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Raw cURL Command</label>
<p className="mb-2 text-xs font-medium text-text-muted">
Paste the full request exactly as supplied by your SMS provider. You can include the entire command.
</p>
<textarea
value={formCurl}
onChange={e => setFormCurl(e.target.value)}
placeholder="curl --request POST --url ..."
className="w-full h-40 px-4 py-2 rounded-lg font-mono text-sm bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue transition resize-none leading-relaxed"
className="h-48 w-full resize-none rounded-lg border border-border-main bg-white px-4 py-3 font-mono text-sm leading-relaxed text-text-primary placeholder-placeholder-bg transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
required
spellCheck="false"
/>
</div>
{!editingId && profiles.length > 0 && (
{!editingId && hasProfiles && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"

View File

@ -82,30 +82,7 @@ async function findBusinessByApplicationId(merchantId, applicationId) {
return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId;
});
if (exactMatch) return exactMatch;
const normalizedBrandLookup = normalizedApplicationId.toLowerCase();
const brandMatches = businesses.filter((business) => normalizeText(business.brandName).toLowerCase() === normalizedBrandLookup);
if (brandMatches.length > 1) {
throw createHttpError(
409,
'Multiple businesses matched the provided applicationId brand fallback',
{
code: 'AMBIGUOUS_BUSINESS_MATCH',
details: {
companyId: merchantId,
applicationId: normalizedApplicationId,
matchedBusinesses: brandMatches.map((business) => ({
businessId: business.businessId,
brandName: business.brandName,
})),
},
}
);
}
return brandMatches[0] || null;
return exactMatch || null;
}
async function findBusinessByBrandName(merchantId, brandName) {
@ -215,6 +192,19 @@ function normalizeWebsiteUrl(value) {
}
}
function normalizeUrlList(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 getBusinessPreviewSummary(source = {}) {
const taglines = Array.isArray(source?.taglines)
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
@ -231,11 +221,17 @@ function getBusinessPreviewSummary(source = {}) {
function mergeBusinessSummary(baseBusiness = {}, context = null) {
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
const relevantImagePaths = normalizeUrlList(
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
? baseBusiness.relevantImagePaths
: context?.relevantImagePaths
);
return {
...baseBusiness,
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
relevantImagePaths,
};
}
@ -290,6 +286,17 @@ function getShipmentBrandName(body) {
);
}
function getShipmentApplicationId(req) {
const shipment = getShipmentPayload(req.body);
return normalizeScopeId(
getApplicationId(req)
|| shipment?.application_id
|| shipment?.affiliate_details?.affiliate_id
|| shipment?.affiliate_details?.id
|| shipment?.affiliate_details?.config?.id
);
}
function getShipmentEventKey(body) {
const shipment = getShipmentPayload(body);
return firstNonEmptyText(
@ -793,7 +800,10 @@ router.get('/', async (req, res) => {
const businesses = await getIndex(merchantId);
const hydratedBusinesses = await Promise.all(
businesses.map(async (business) => {
if (normalizeText(business.previewTagline) || normalizeText(business.previewImagePath)) {
const hasPreviewSummary = normalizeText(business.previewTagline) || normalizeText(business.previewImagePath);
const hasRelevantImagePaths = Array.isArray(business.relevantImagePaths) && business.relevantImagePaths.length > 0;
if (hasPreviewSummary && hasRelevantImagePaths) {
return mergeBusinessSummary(business);
}
@ -874,6 +884,19 @@ router.post('/', async (req, res) => {
};
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 });
@ -887,12 +910,16 @@ router.post('/', async (req, res) => {
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);
res.json({
...contextJson,
scrapeArtifacts,
});
} catch (err) {
console.error('Create business error:', err.message);
sendRouteError(res, err);
@ -937,20 +964,22 @@ router.post('/resolve-template', async (req, res) => {
const companyId = getCompanyId(req);
const shipment = getShipmentPayload(req.body);
const brandName = getShipmentBrandName(req.body);
const applicationId = getShipmentApplicationId(req);
const event = getShipmentEventKey(req.body);
const toNumber = getShipmentToNumber(req.body);
if (!companyId) return res.status(400).json({ error: 'companyId is required' });
if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' });
if (!brandName) {
if (!applicationId) {
return res.status(400).json({
error: 'A shipment brand name is required',
error: 'A shipment applicationId is required',
details: {
acceptedPaths: [
'payload.shipment.bags[0].brand.brand_name',
'payload.shipment.bags[0].item.attributes.brand_name',
'payload.shipment.affiliate_details.company_affiliate_tag',
'application_id',
'payload.shipment.application_id',
'payload.shipment.affiliate_details.affiliate_id',
'payload.shipment.affiliate_details.id',
'payload.shipment.affiliate_details.config.id',
],
},
});
@ -980,9 +1009,9 @@ router.post('/resolve-template', async (req, res) => {
});
}
const business = await findBusinessByBrandName(companyId, brandName);
const business = await findBusinessByApplicationId(companyId, applicationId);
if (!business) {
return res.status(404).json({ error: 'Business not found for brand name' });
return res.status(404).json({ error: 'Business not found for applicationId' });
}
const eventSlug = slugify(event);
@ -1008,7 +1037,8 @@ router.post('/resolve-template', async (req, res) => {
success: true,
companyId,
businessId: business.businessId,
brandName,
applicationId,
brandName: business.brandName || getShipmentBrandName(req.body),
event: eventSlug,
matchedTemplateEvent: matchedSlug || eventSlug,
templateId: normalizeText(tmpl.templateId),

View File

@ -2,7 +2,7 @@ const axios = require('axios');
/**
* Scrape a website using Firecrawl.
* Returns { markdown, links } raw output passed to OpenAI.
* Returns normalized fields plus the raw response payload for downstream UI/debug use.
*/
async function scrape(url) {
const apiKey = process.env.FIRECRAWL_API_KEY;
@ -30,8 +30,11 @@ async function scrape(url) {
);
return {
markdown: data.markdown || '',
links: data.links || [],
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 : {},
};
} catch (err) {
throw err;