Major changes: UI, sales-channel API's application ID matching with pixelbin storage instead of brand name in payload
This commit is contained in:
parent
c6fee788f0
commit
3ca86c53c6
|
|
@ -18,7 +18,7 @@ function SubLayout({ children }) {
|
||||||
{hasGlobalSms && (
|
{hasGlobalSms && (
|
||||||
<Link
|
<Link
|
||||||
to={`/${activeBusinessId}/settings`}
|
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"
|
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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
export default function RegisterBusinessModal({ onClose }) {
|
export default function RegisterBusinessModal({ onClose, onSuccess }) {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
@ -14,9 +14,15 @@ export default function RegisterBusinessModal({ onClose }) {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/businesses', {
|
const res = await apiClient.post('/api/businesses', {
|
||||||
websiteUrl: url.trim(),
|
websiteUrl: url.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof onSuccess === 'function') {
|
||||||
|
await onSuccess(res.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Something went wrong. Please try again.');
|
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 ">
|
<div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
|
||||||
|
|
||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<div className="text-center">
|
<div>
|
||||||
<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>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 mb-3">Business created</p>
|
||||||
<h2 className="text-xl font-bold text-gray-800 mb-2">Business Added!</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">Storefront captured successfully</h2>
|
||||||
<p className="text-gray-500 text-sm mb-6 font-medium">Your business has been registered successfully.</p>
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
|
className="w-full py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-medium transition"
|
||||||
>
|
>
|
||||||
Done
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -74,7 +86,7 @@ export default function RegisterBusinessModal({ onClose }) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={status === 'loading'}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function StageMarker({ done, active, enabled }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (active) {
|
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) {
|
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 ${
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||||
item.active
|
item.active
|
||||||
? 'bg-gray-100/70 text-gray-800'
|
? '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]}
|
{SVG_ICONS[item.id]}
|
||||||
|
|
@ -203,13 +203,13 @@ export default function Sidebar() {
|
||||||
{item.substeps.map((substep) => (
|
{item.substeps.map((substep) => (
|
||||||
<div key={substep.id} className="relative flex items-center pr-3 group cursor-default">
|
<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">
|
<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>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 px-3 py-2 rounded-md text-[14px] transition-colors ${
|
className={`flex-1 px-3 py-2 rounded-md text-[14px] transition-colors ${
|
||||||
substep.active
|
substep.active
|
||||||
? 'bg-white text-primary-blue font-semibold'
|
? '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}
|
{substep.label}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={sending}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,40 @@ body {
|
||||||
-webkit-font-smoothing: antialiased;
|
-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 {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) {
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={deleting}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -116,7 +116,7 @@ export default function Brand() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
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
|
Delete Brand
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -182,7 +182,7 @@ export default function Brand() {
|
||||||
<Link
|
<Link
|
||||||
key={card.to}
|
key={card.to}
|
||||||
to={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>
|
<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>
|
<p className="font-semibold text-gray-800 mb-1">{card.label}</p>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,110 @@ import {
|
||||||
getBusinessTagline,
|
getBusinessTagline,
|
||||||
} from '../utils/businessProfile';
|
} 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 }) {
|
function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
|
||||||
|
|
@ -27,7 +131,7 @@ function DeleteConfirmModal({ businessName, onCancel, onConfirm, deleting }) {
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={deleting}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -49,35 +153,137 @@ function BusinessCreatedModal({ business, onClose }) {
|
||||||
const domain = getBusinessDomain(business);
|
const domain = getBusinessDomain(business);
|
||||||
const tagline = getBusinessTagline(business);
|
const tagline = getBusinessTagline(business);
|
||||||
const image = getBusinessImage(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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5 w-full max-w-md ">
|
<div
|
||||||
<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>
|
className="w-full overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl"
|
||||||
<h2 className="text-xl font-bold text-gray-800 mb-2 text-center">Business Added!</h2>
|
style={{ width: `${modalWidth}px`, maxWidth: 'calc(100vw - 2rem)', maxHeight: '88vh' }}
|
||||||
<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 justify-between gap-4 border-b border-gray-200 px-6 py-5">
|
||||||
<div className="flex items-start gap-4">
|
<div>
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-white border border-gray-200 shrink-0 flex items-center justify-center">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Business created</p>
|
||||||
{image ? (
|
<h2 className="mt-2 text-2xl font-semibold text-gray-900">{name}</h2>
|
||||||
<img src={image} alt={name} className="w-full h-full object-cover" />
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
) : (
|
{domain
|
||||||
<span className="text-xl font-bold text-primary-blue">{name?.[0]?.toUpperCase() || 'B'}</span>
|
? `Scrape completed for ${domain}. Review the captured assets below before moving on.`
|
||||||
)}
|
: 'Scrape completed. Review the captured assets below before moving on.'}
|
||||||
</div>
|
</p>
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
<p className="text-primary-blue font-bold text-lg tracking-tight truncate">{name}</p>
|
<button
|
||||||
{domain && <p className="text-sm text-gray-500 font-medium truncate mt-0.5">{domain}</p>}
|
onClick={onClose}
|
||||||
{tagline && <p className="text-sm text-gray-700 mt-2 leading-relaxed line-clamp-2">{tagline}</p>}
|
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>
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -88,16 +294,14 @@ function StatusBadge({ status }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${isScraped
|
||||||
isScraped
|
|
||||||
? 'bg-green-100 text-green-700'
|
? 'bg-green-100 text-green-700'
|
||||||
: 'bg-amber-100 text-amber-700'
|
: 'bg-amber-100 text-amber-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-2 w-2 rounded-full ${
|
className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
|
||||||
isScraped ? 'bg-green-500' : 'bg-amber-500'
|
}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
|
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -121,12 +325,35 @@ function UnifiedBusinessCard({
|
||||||
const domain = getBusinessDomain(entity);
|
const domain = getBusinessDomain(entity);
|
||||||
const tagline = getBusinessTagline(entity);
|
const tagline = getBusinessTagline(entity);
|
||||||
const isScraped = item.status === 'scraped';
|
const isScraped = item.status === 'scraped';
|
||||||
|
const cdnUrls = extractCdnUrls(item.business);
|
||||||
const isOpening = isScraped && selectingBusinessId === businessId;
|
const isOpening = isScraped && selectingBusinessId === businessId;
|
||||||
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
||||||
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
|
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 (
|
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="p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-4 min-w-0">
|
<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.
|
A website URL could not be derived automatically for this sales channel.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isScraped && cdnUrls.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Images</p>
|
||||||
|
<span className="text-xs font-medium text-gray-400">
|
||||||
|
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CdnGallery urls={cdnUrls.slice(0, 6)} compact showLabels={false} clickable={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3">
|
<div className="px-5 py-3 bg-white border-t border-gray-100 flex items-center justify-between gap-3">
|
||||||
{isScraped ? (
|
{isScraped ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="text-sm text-primary-blue font-semibold group-hover:underline disabled:opacity-60"
|
onClick={(event) => {
|
||||||
onClick={() => onSelect(item.business)}
|
event.stopPropagation();
|
||||||
disabled={isOpening}
|
onDelete(item.business);
|
||||||
>
|
}}
|
||||||
{isOpening ? 'Opening…' : 'Manage →'}
|
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"
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(item.business)}
|
|
||||||
className="text-xs text-gray-600 hover:text-gray-700 font-medium transition"
|
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -320,6 +555,18 @@ export default function Businesses() {
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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) {
|
async function handleSelect(biz) {
|
||||||
setSelectingBusinessId(biz.businessId);
|
setSelectingBusinessId(biz.businessId);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
@ -350,9 +597,7 @@ export default function Businesses() {
|
||||||
applicationId,
|
applicationId,
|
||||||
websiteUrl: channel.websiteUrl,
|
websiteUrl: channel.websiteUrl,
|
||||||
});
|
});
|
||||||
setCreatedBusiness(res.data);
|
await handleBusinessCreated(res.data);
|
||||||
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
|
||||||
setSalesChannelsStatus('success');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to add business from sales channel');
|
setError(err.response?.data?.error || 'Failed to add business from sales channel');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -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>
|
<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
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
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
|
Use website URL fallback
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -501,7 +746,10 @@ export default function Businesses() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<RegisterBusinessModal onClose={() => { setShowModal(false); load(); }} />
|
<RegisterBusinessModal
|
||||||
|
onClose={() => { setShowModal(false); load(); }}
|
||||||
|
onSuccess={handleBusinessCreated}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
{createdBusiness && <BusinessCreatedModal business={createdBusiness} onClose={() => setCreatedBusiness(null)} />}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,13 @@ const EVENT_GROUPS = [
|
||||||
id: 'fulfillment',
|
id: 'fulfillment',
|
||||||
label: 'Order & Fulfillment',
|
label: 'Order & Fulfillment',
|
||||||
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
|
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
|
||||||
defaultExpanded: true,
|
defaultExpanded: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delivery',
|
id: 'delivery',
|
||||||
label: 'Delivery Journey',
|
label: 'Delivery Journey',
|
||||||
description: 'Courier pickup, in-transit updates, and final handover milestones.',
|
description: 'Courier pickup, in-transit updates, and final handover milestones.',
|
||||||
defaultExpanded: true,
|
defaultExpanded: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cancellations',
|
id: 'cancellations',
|
||||||
|
|
@ -101,22 +101,73 @@ const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
|
||||||
}, {});
|
}, {});
|
||||||
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
||||||
unselected: {
|
unselected: {
|
||||||
label: 'No template selected',
|
label: 'Not Selected',
|
||||||
wrapper: 'border-gray-200 bg-white text-gray-500',
|
badge: 'border-gray-200 bg-white text-gray-500',
|
||||||
dot: 'bg-gray-400',
|
dot: 'bg-gray-400',
|
||||||
},
|
},
|
||||||
pending_whitelisting: {
|
pending_whitelisting: {
|
||||||
label: 'Pending Whitelisting',
|
label: 'Pending Whitelisting',
|
||||||
wrapper: 'border-gray-200 bg-white text-gray-700',
|
badge: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
dot: 'bg-white0',
|
dot: 'bg-amber-500',
|
||||||
},
|
},
|
||||||
whitelisted: {
|
whitelisted: {
|
||||||
label: 'Published',
|
label: 'Published',
|
||||||
wrapper: 'border-gray-200 bg-white text-gray-700',
|
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
dot: 'bg-white0',
|
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) {
|
function getEventGroupId(event) {
|
||||||
const slug = String(event?.slug || '');
|
const slug = String(event?.slug || '');
|
||||||
|
|
||||||
|
|
@ -212,17 +263,16 @@ function buildTemplateUiState(templates = []) {
|
||||||
const nextVariants = {};
|
const nextVariants = {};
|
||||||
const nextGenState = {};
|
const nextGenState = {};
|
||||||
const nextTemplateStatusBySlug = {};
|
const nextTemplateStatusBySlug = {};
|
||||||
|
const nextSelectedTemplateBySlug = {};
|
||||||
|
|
||||||
templates.forEach((template) => {
|
templates.forEach((template) => {
|
||||||
if (!template?.eventSlug) return;
|
if (!template?.eventSlug) return;
|
||||||
|
|
||||||
if (template.selectedTemplate) {
|
if (template.selectedTemplate) {
|
||||||
if (template.status === 'whitelisted') {
|
const normalizedStatus = normalizeTemplateStatus(template.status);
|
||||||
nextTemplateStatusBySlug[template.eventSlug] = 'whitelisted';
|
nextTemplateStatusBySlug[template.eventSlug] = normalizedStatus;
|
||||||
} else {
|
|
||||||
nextTemplateStatusBySlug[template.eventSlug] = 'pending_whitelisting';
|
|
||||||
}
|
|
||||||
nextGenState[template.eventSlug] = 'selected';
|
nextGenState[template.eventSlug] = 'selected';
|
||||||
|
nextSelectedTemplateBySlug[template.eventSlug] = buildSelectedTemplatePreview(template);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +282,7 @@ function buildTemplateUiState(templates = []) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { nextVariants, nextGenState, nextTemplateStatusBySlug };
|
return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Events() {
|
export default function Events() {
|
||||||
|
|
@ -253,6 +303,7 @@ export default function Events() {
|
||||||
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
|
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
|
||||||
const [activeCaretVariantKey, setActiveCaretVariantKey] = useState('');
|
const [activeCaretVariantKey, setActiveCaretVariantKey] = useState('');
|
||||||
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
|
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
|
||||||
|
const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||||
|
|
||||||
|
|
@ -283,13 +334,19 @@ export default function Events() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const templates = templatesRes.data.templates || [];
|
const templates = templatesRes.data.templates || [];
|
||||||
const { nextVariants, nextGenState, nextTemplateStatusBySlug } = buildTemplateUiState(templates);
|
const {
|
||||||
|
nextVariants,
|
||||||
|
nextGenState,
|
||||||
|
nextTemplateStatusBySlug,
|
||||||
|
nextSelectedTemplateBySlug,
|
||||||
|
} = buildTemplateUiState(templates);
|
||||||
|
|
||||||
setEvents(eventsRes.data.events || []);
|
setEvents(eventsRes.data.events || []);
|
||||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||||
setVariants(nextVariants);
|
setVariants(nextVariants);
|
||||||
setGenState(nextGenState);
|
setGenState(nextGenState);
|
||||||
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||||||
|
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
|
||||||
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load events');
|
setError('Failed to load events');
|
||||||
|
|
@ -353,6 +410,11 @@ export default function Events() {
|
||||||
delete nextStatuses[slug];
|
delete nextStatuses[slug];
|
||||||
return nextStatuses;
|
return nextStatuses;
|
||||||
});
|
});
|
||||||
|
setSelectedTemplateBySlug((currentTemplates) => {
|
||||||
|
const nextTemplates = { ...currentTemplates };
|
||||||
|
delete nextTemplates[slug];
|
||||||
|
return nextTemplates;
|
||||||
|
});
|
||||||
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Generation failed');
|
setError(err.response?.data?.error || 'Generation failed');
|
||||||
|
|
@ -413,7 +475,8 @@ export default function Events() {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
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);
|
await refreshOnboardingState(businessId).catch(() => null);
|
||||||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
||||||
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
||||||
|
|
@ -421,6 +484,10 @@ export default function Events() {
|
||||||
setActiveCaretVariantKey('');
|
setActiveCaretVariantKey('');
|
||||||
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
||||||
setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' }));
|
setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' }));
|
||||||
|
setSelectedTemplateBySlug((currentTemplates) => ({
|
||||||
|
...currentTemplates,
|
||||||
|
[slug]: selectedTemplatePreview,
|
||||||
|
}));
|
||||||
if (shouldAutoAdvance) {
|
if (shouldAutoAdvance) {
|
||||||
navigate(`/${businessId}/templates?event=${encodeURIComponent(slug)}`);
|
navigate(`/${businessId}/templates?event=${encodeURIComponent(slug)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -555,7 +622,7 @@ export default function Events() {
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm((visible) => !visible)}
|
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'}
|
{showAddForm ? 'Cancel' : '+ Add Event'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -605,37 +672,31 @@ export default function Events() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{groupedEvents.map((group) => {
|
{groupedEvents.map((group) => {
|
||||||
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
|
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
|
||||||
|
const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
|
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleGroup(group.id)}
|
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="flex min-w-0 items-start gap-4">
|
||||||
<div className={`mt-1 h-3 w-3 rounded-full ${
|
<div className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${groupStyle.markerShell}`}>
|
||||||
group.id === 'fulfillment' ? 'bg-gray-400' :
|
<span className={`h-2.5 w-2.5 rounded-full ${groupStyle.markerDot}`} />
|
||||||
group.id === 'delivery' ? 'bg-sky-500' :
|
</div>
|
||||||
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="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
|
<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
|
{group.events.length} events
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
|
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</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 ${
|
<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' : ''
|
||||||
isExpanded ? 'rotate-180' : ''
|
}`}>
|
||||||
}`}>
|
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -645,307 +706,323 @@ export default function Events() {
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
|
<div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{group.events.map((event) => {
|
{group.events.map((event) => {
|
||||||
const state = genState[event.slug] || 'idle';
|
const state = genState[event.slug] || 'idle';
|
||||||
const eventVariants = variants[event.slug] || [];
|
const eventVariants = variants[event.slug] || [];
|
||||||
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
||||||
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
||||||
const canViewTemplate = templateStatus !== 'unselected';
|
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
|
||||||
|
const canViewTemplate = templateStatus !== 'unselected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
|
<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 flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{event.isDefault ? (
|
{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">
|
<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>
|
<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>
|
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => handleValidateEdit(event.slug, index)}
|
onClick={() => handleDelete(event.slug)}
|
||||||
disabled={!canRunCheck || isSelectingThis || isSelectingAnotherVariant}
|
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"
|
||||||
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"
|
title="Delete event"
|
||||||
>
|
>
|
||||||
{validationStatus === 'checking'
|
<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>
|
||||||
? 'Checking…'
|
|
||||||
: validationStatus === 'rejected' && currentMatchesCheckedText
|
|
||||||
? 'Check again'
|
|
||||||
: 'Check edit'}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ export default function GlobalSms() {
|
||||||
}, [loadProfiles]);
|
}, [loadProfiles]);
|
||||||
|
|
||||||
const activeProfile = profiles.find(p => p.id === activeProfileId) || null;
|
const activeProfile = profiles.find(p => p.id === activeProfileId) || null;
|
||||||
|
const hasProfiles = profiles.length > 0;
|
||||||
|
const isCreatingFirstProfile = !hasProfiles && !editingId;
|
||||||
const pData = activeProfile?.provider || {};
|
const pData = activeProfile?.provider || {};
|
||||||
const missingFields = [];
|
const missingFields = [];
|
||||||
if (activeProfile && !pData.providerName) missingFields.push('providerName');
|
if (activeProfile && !pData.providerName) missingFields.push('providerName');
|
||||||
|
|
@ -303,104 +305,140 @@ export default function GlobalSms() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profiles List */}
|
{hasProfiles && (
|
||||||
<div className="space-y-4 pt-4 border-t border-border-soft">
|
<div className="space-y-4 pt-4 border-t border-border-soft">
|
||||||
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3>
|
<h3 className="font-bold text-text-primary text-lg">All Profiles</h3>
|
||||||
{profiles.length > 0 ? (
|
{profiles.map(p => {
|
||||||
profiles.map(p => {
|
|
||||||
const isActive = p.id === activeProfileId;
|
const isActive = p.id === activeProfileId;
|
||||||
return (
|
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
|
||||||
<div className="flex-1 overflow-hidden">
|
key={p.id}
|
||||||
<div className="flex items-center gap-3 mb-1">
|
className={`rounded-xl border p-5 transition-colors ${
|
||||||
<h3 className="font-bold text-text-primary text-base truncate">{p.name}</h3>
|
isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'
|
||||||
{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
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
</span>
|
<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 && (
|
<button
|
||||||
<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">
|
onClick={() => handleEditClick(p)}
|
||||||
Default
|
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"
|
||||||
</span>
|
>
|
||||||
|
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>
|
</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>
|
</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>
|
|
||||||
|
|
||||||
{/* Inline Form (Create / Edit) */}
|
{/* Inline Form (Create / Edit) */}
|
||||||
<div className="bg-surface-white border border-border-main rounded-lg overflow-hidden">
|
<div
|
||||||
<div className="px-6 py-4 border-b border-border-main bg-table-header flex items-center justify-between">
|
className={`overflow-hidden rounded-xl border ${
|
||||||
<h3 className="font-bold text-text-primary text-md">
|
isCreatingFirstProfile ? 'border-indigo-100 bg-white shadow-sm' : 'border-border-main bg-surface-white'
|
||||||
{editingId ? 'Edit Profile' : 'Add New Profile'}
|
}`}
|
||||||
</h3>
|
>
|
||||||
|
<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 && (
|
{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
|
Switch to Add New
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Profile Name</label>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formName}
|
value={formName}
|
||||||
onChange={e => setFormName(e.target.value)}
|
onChange={e => setFormName(e.target.value)}
|
||||||
placeholder="e.g. Production SMS, Staging Twilio"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Raw cURL Command</label>
|
<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
|
<textarea
|
||||||
value={formCurl}
|
value={formCurl}
|
||||||
onChange={e => setFormCurl(e.target.value)}
|
onChange={e => setFormCurl(e.target.value)}
|
||||||
placeholder="curl --request POST --url ..."
|
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
|
required
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!editingId && profiles.length > 0 && (
|
{!editingId && hasProfiles && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -82,30 +82,7 @@ async function findBusinessByApplicationId(merchantId, applicationId) {
|
||||||
return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId;
|
return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exactMatch) return exactMatch;
|
return exactMatch || null;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findBusinessByBrandName(merchantId, brandName) {
|
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 = {}) {
|
function getBusinessPreviewSummary(source = {}) {
|
||||||
const taglines = Array.isArray(source?.taglines)
|
const taglines = Array.isArray(source?.taglines)
|
||||||
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean)
|
||||||
|
|
@ -231,11 +221,17 @@ function getBusinessPreviewSummary(source = {}) {
|
||||||
|
|
||||||
function mergeBusinessSummary(baseBusiness = {}, context = null) {
|
function mergeBusinessSummary(baseBusiness = {}, context = null) {
|
||||||
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
|
const previewSummary = getBusinessPreviewSummary(context || baseBusiness);
|
||||||
|
const relevantImagePaths = normalizeUrlList(
|
||||||
|
Array.isArray(baseBusiness?.relevantImagePaths) && baseBusiness.relevantImagePaths.length
|
||||||
|
? baseBusiness.relevantImagePaths
|
||||||
|
: context?.relevantImagePaths
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseBusiness,
|
...baseBusiness,
|
||||||
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
|
previewTagline: normalizeText(baseBusiness.previewTagline) || previewSummary.previewTagline,
|
||||||
previewImagePath: normalizeText(baseBusiness.previewImagePath) || previewSummary.previewImagePath,
|
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) {
|
function getShipmentEventKey(body) {
|
||||||
const shipment = getShipmentPayload(body);
|
const shipment = getShipmentPayload(body);
|
||||||
return firstNonEmptyText(
|
return firstNonEmptyText(
|
||||||
|
|
@ -793,7 +800,10 @@ router.get('/', async (req, res) => {
|
||||||
const businesses = await getIndex(merchantId);
|
const businesses = await getIndex(merchantId);
|
||||||
const hydratedBusinesses = await Promise.all(
|
const hydratedBusinesses = await Promise.all(
|
||||||
businesses.map(async (business) => {
|
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);
|
return mergeBusinessSummary(business);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -874,6 +884,19 @@ router.post('/', async (req, res) => {
|
||||||
};
|
};
|
||||||
await uploadJSON(bizRoot, 'context', contextJson);
|
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
|
// 5. Init events.json
|
||||||
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
|
await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS });
|
||||||
|
|
||||||
|
|
@ -887,12 +910,16 @@ router.post('/', async (req, res) => {
|
||||||
domain: contextJson.domain,
|
domain: contextJson.domain,
|
||||||
previewTagline: previewSummary.previewTagline,
|
previewTagline: previewSummary.previewTagline,
|
||||||
previewImagePath: previewSummary.previewImagePath,
|
previewImagePath: previewSummary.previewImagePath,
|
||||||
|
relevantImagePaths: normalizeUrlList(contextJson.relevantImagePaths),
|
||||||
createdAt: contextJson.createdAt,
|
createdAt: contextJson.createdAt,
|
||||||
updatedAt: contextJson.updatedAt,
|
updatedAt: contextJson.updatedAt,
|
||||||
});
|
});
|
||||||
await saveIndex(merchantId, businesses);
|
await saveIndex(merchantId, businesses);
|
||||||
|
|
||||||
res.json(contextJson);
|
res.json({
|
||||||
|
...contextJson,
|
||||||
|
scrapeArtifacts,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create business error:', err.message);
|
console.error('Create business error:', err.message);
|
||||||
sendRouteError(res, err);
|
sendRouteError(res, err);
|
||||||
|
|
@ -937,20 +964,22 @@ router.post('/resolve-template', async (req, res) => {
|
||||||
|
|
||||||
const companyId = getCompanyId(req);
|
const companyId = getCompanyId(req);
|
||||||
const shipment = getShipmentPayload(req.body);
|
const shipment = getShipmentPayload(req.body);
|
||||||
const brandName = getShipmentBrandName(req.body);
|
const applicationId = getShipmentApplicationId(req);
|
||||||
const event = getShipmentEventKey(req.body);
|
const event = getShipmentEventKey(req.body);
|
||||||
const toNumber = getShipmentToNumber(req.body);
|
const toNumber = getShipmentToNumber(req.body);
|
||||||
|
|
||||||
if (!companyId) return res.status(400).json({ error: 'companyId is required' });
|
if (!companyId) return res.status(400).json({ error: 'companyId is required' });
|
||||||
if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' });
|
if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' });
|
||||||
if (!brandName) {
|
if (!applicationId) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'A shipment brand name is required',
|
error: 'A shipment applicationId is required',
|
||||||
details: {
|
details: {
|
||||||
acceptedPaths: [
|
acceptedPaths: [
|
||||||
'payload.shipment.bags[0].brand.brand_name',
|
'application_id',
|
||||||
'payload.shipment.bags[0].item.attributes.brand_name',
|
'payload.shipment.application_id',
|
||||||
'payload.shipment.affiliate_details.company_affiliate_tag',
|
'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) {
|
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);
|
const eventSlug = slugify(event);
|
||||||
|
|
@ -1008,7 +1037,8 @@ router.post('/resolve-template', async (req, res) => {
|
||||||
success: true,
|
success: true,
|
||||||
companyId,
|
companyId,
|
||||||
businessId: business.businessId,
|
businessId: business.businessId,
|
||||||
brandName,
|
applicationId,
|
||||||
|
brandName: business.brandName || getShipmentBrandName(req.body),
|
||||||
event: eventSlug,
|
event: eventSlug,
|
||||||
matchedTemplateEvent: matchedSlug || eventSlug,
|
matchedTemplateEvent: matchedSlug || eventSlug,
|
||||||
templateId: normalizeText(tmpl.templateId),
|
templateId: normalizeText(tmpl.templateId),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const axios = require('axios');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrape a website using Firecrawl.
|
* 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) {
|
async function scrape(url) {
|
||||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||||
|
|
@ -30,8 +30,11 @@ async function scrape(url) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
markdown: data.markdown || '',
|
markdown: typeof data?.markdown === 'string' ? data.markdown : '',
|
||||||
links: data.links || [],
|
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) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user