Compare commits

..

No commits in common. "11dba17f98b525e88114781e73d1926ab43c689a" and "4f9fd366109a00812ae72ffd494cbbab59b139b3" have entirely different histories.

21 changed files with 1674 additions and 8530 deletions

View File

@ -11,8 +11,6 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apk add --no-cache curl
COPY server/package*.json ./ COPY server/package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev

6
boltic.yaml Normal file
View File

@ -0,0 +1,6 @@
app: sms-extension-backend
region: asia-south1
build:
dockerfile: "Dockerfile"
ignorefile: ".gitignore"

View File

@ -5,13 +5,14 @@ import apiClient from './api/client';
import BusinessReviewModal from './components/BusinessReviewModal'; import BusinessReviewModal from './components/BusinessReviewModal';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Businesses from './pages/Businesses'; import Businesses from './pages/Businesses';
import Providers from './pages/Providers';
import GlobalSms from './pages/GlobalSms'; import GlobalSms from './pages/GlobalSms';
import Analytics from './pages/Analytics';
import Events from './pages/Events'; import Events from './pages/Events';
import Templates from './pages/Templates'; import Templates from './pages/Templates';
import { Link } from 'react-router-dom';
function SubLayout({ children }) { function SubLayout({ children }) {
const { activeBusiness, activeBusinessId } = useBusiness(); const { activeBusiness, activeBusinessId, hasGlobalSms } = useBusiness();
const [reviewBusiness, setReviewBusiness] = useState(null); const [reviewBusiness, setReviewBusiness] = useState(null);
const [reviewLoading, setReviewLoading] = useState(false); const [reviewLoading, setReviewLoading] = useState(false);
const [reviewError, setReviewError] = useState(''); const [reviewError, setReviewError] = useState('');
@ -46,7 +47,17 @@ function SubLayout({ children }) {
reviewError={reviewError} reviewError={reviewError}
/> />
<main className="flex-1 ml-60 flex flex-col"> <main className="flex-1 ml-60 flex flex-col">
<header className="h-16 border-b border-border-main bg-white shrink-0" /> <header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
{hasGlobalSms && (
<Link
to={`/${activeBusinessId}/settings`}
className="flex h-10 w-10 items-center justify-center rounded-full border border-border-soft bg-gray-50 text-gray-500 transition-colors hover:border-gray-300 hover:bg-white hover:text-primary-blue"
title="Settings"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</Link>
)}
</header>
<div className="flex-1 p-5 overflow-auto"> <div className="flex-1 p-5 overflow-auto">
{children} {children}
</div> </div>
@ -94,12 +105,12 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Businesses />} /> <Route path="/" element={<Businesses />} />
<Route path="/:businessId/settings" element={
<BusinessGuard><SubLayout><Providers /></SubLayout></BusinessGuard>
} />
<Route path="/:businessId/global-sms" element={ <Route path="/:businessId/global-sms" element={
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard> <BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
} /> } />
<Route path="/:businessId/analytics" element={
<BusinessGuard><SubLayout><Analytics /></SubLayout></BusinessGuard>
} />
<Route path="/:businessId/events" element={ <Route path="/:businessId/events" element={
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard> <BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
} /> } />

View File

@ -1,13 +1,7 @@
import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
import { getBusinessImage } from '../utils/businessProfile';
const SVG_ICONS = { const SVG_ICONS = {
analytics: (
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 20V10m5 10V4m5 16v-6M4 20h16" />
</svg>
),
globalSms: ( globalSms: (
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
@ -65,28 +59,54 @@ function StageMarker({ done, active, enabled }) {
return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />; return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />;
} }
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function getSidebarBusinessImage(business) {
const brandingLogos = business?.scrapeArtifacts?.json?.branding?.logos;
const primaryLogo = Array.isArray(brandingLogos)
? brandingLogos.find((entry) => normalizeText(entry))
: '';
if (primaryLogo) return primaryLogo;
return (
normalizeText(business?.logoUrl)
|| normalizeText(business?.imageUrl)
|| (Array.isArray(business?.relevantImagePaths)
? business.relevantImagePaths.find((entry) => normalizeText(entry)) || ''
: '')
);
}
export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) { export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) {
const { const {
activeBusiness, activeBusiness,
activeBusinessId, activeBusinessId,
clearBusiness, clearBusiness,
hasGlobalSms,
isSetupComplete, isSetupComplete,
hasSelectedTemplates, hasSelectedTemplates,
} = useBusiness(); } = useBusiness();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const businessImage = getBusinessImage(activeBusiness); const businessImage = getSidebarBusinessImage(activeBusiness);
const analyticsPath = `/${activeBusinessId}/analytics`;
const globalSmsPath = `/${activeBusinessId}/global-sms`; const globalSmsPath = `/${activeBusinessId}/global-sms`;
const eventsPath = `/${activeBusinessId}/events`; const eventsPath = `/${activeBusinessId}/events`;
const templatesPath = `/${activeBusinessId}/templates`; const templatesPath = `/${activeBusinessId}/templates`;
const isAnalyticsRoute = location.pathname === analyticsPath;
const isGlobalSmsRoute = location.pathname === globalSmsPath; const isGlobalSmsRoute = location.pathname === globalSmsPath;
const isEventsRoute = location.pathname === eventsPath; const isEventsRoute = location.pathname === eventsPath;
const isTemplatesRoute = location.pathname === templatesPath; const isTemplatesRoute = location.pathname === templatesPath;
const omniSubsteps = [
{ id: 'profile', label: 'Add / Select Profile', done: hasGlobalSms, active: isGlobalSmsRoute && !hasGlobalSms },
{ id: 'validate', label: 'Validate cURL', done: hasGlobalSms, active: false },
{ id: 'fields', label: 'Complete Fields', done: isSetupComplete, active: isGlobalSmsRoute && hasGlobalSms && !isSetupComplete },
{ id: 'ready', label: 'Ready', done: isSetupComplete, active: isGlobalSmsRoute && isSetupComplete },
];
const stepItems = [ const stepItems = [
{ {
id: 'globalSms', id: 'globalSms',
@ -95,7 +115,8 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
enabled: true, enabled: true,
done: isSetupComplete && !isGlobalSmsRoute, done: isSetupComplete && !isGlobalSmsRoute,
active: isGlobalSmsRoute, active: isGlobalSmsRoute,
expanded: false, expanded: isGlobalSmsRoute,
substeps: omniSubsteps,
}, },
{ {
id: 'events', id: 'events',
@ -185,27 +206,6 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-3 pt-5"> <nav className="flex-1 px-3 pt-5">
<div className="mb-4">
{isSetupComplete ? (
<NavLink
to={analyticsPath}
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold transition-colors ${
isAnalyticsRoute
? 'bg-gray-100 text-gray-900'
: 'text-gray-600 hover:bg-page-bg hover:text-gray-900'
}`}
>
{SVG_ICONS.analytics}
<span className="flex-1 truncate">Analytics</span>
</NavLink>
) : (
<div className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-gray-300 cursor-not-allowed select-none">
{SVG_ICONS.analytics}
<span className="flex-1 truncate">Analytics</span>
</div>
)}
</div>
<div className="space-y-1"> <div className="space-y-1">
{stepItems.map((item, index) => ( {stepItems.map((item, index) => (
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2"> <div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">

View File

@ -126,10 +126,6 @@ export default function TemplateDetailWorkspaceModal({
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet'; const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
const provider = boundProfile?.provider || {}; const provider = boundProfile?.provider || {};
const samplePayloadText = JSON.stringify(samplePayload, null, 2); const samplePayloadText = JSON.stringify(samplePayload, null, 2);
const executionMeta = template?.executionMeta || {};
const executionInputCount = Array.isArray(template?.requiredInputs) ? template.requiredInputs.length : 0;
const fallbackCount = previewState.fallbackPlaceholders.length;
const unresolvedCount = previewState.unresolvedPlaceholders.length;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
@ -205,7 +201,7 @@ export default function TemplateDetailWorkspaceModal({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Preview</p> <p className="text-sm font-semibold text-gray-700">Preview</p>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500"> <span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
Deterministic sample render Sample render
</span> </span>
</div> </div>
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4"> <div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
@ -213,13 +209,6 @@ export default function TemplateDetailWorkspaceModal({
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'} {renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
</p> </p>
</div> </div>
{(fallbackCount > 0 || unresolvedCount > 0) && (
<p className={`text-xs font-medium ${unresolvedCount > 0 ? 'text-amber-700' : 'text-gray-500'}`}>
{unresolvedCount > 0
? `${unresolvedCount} placeholder${unresolvedCount === 1 ? '' : 's'} still need explicit mapping.`
: `${fallbackCount} placeholder${fallbackCount === 1 ? '' : 's'} used deterministic sample fallback values.`}
</p>
)}
</section> </section>
</div> </div>
</div> </div>
@ -265,29 +254,6 @@ export default function TemplateDetailWorkspaceModal({
</div> </div>
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Render Strategy</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{executionMeta.renderStrategy === 'deterministic_sample_payload'
? 'Deterministic sample payload'
: 'Template variable mapping'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Execution Inputs</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{executionInputCount} stored input{executionInputCount === 1 ? '' : 's'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Template Variables</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{Number.isFinite(executionMeta.placeholderCount) ? executionMeta.placeholderCount : 0} placeholder{executionMeta.placeholderCount === 1 ? '' : 's'}
</div>
</div>
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label> <label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700"> <div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">

View File

@ -83,19 +83,6 @@ export default function TestSmsModal({ businessId, template, onClose }) {
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'} {result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>} {result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
</div> </div>
{result.renderedContent && (
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Rendered SMS Content</label>
<pre className="p-3 bg-white border border-gray-200 rounded-lg text-xs font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap break-words">
{result.renderedContent}
</pre>
{Array.isArray(result.renderState?.fallbackPlaceholders) && result.renderState.fallbackPlaceholders.length > 0 && (
<p className="mt-2 text-xs font-medium text-gray-500">
{result.renderState.fallbackPlaceholders.length} placeholder{result.renderState.fallbackPlaceholders.length === 1 ? '' : 's'} used deterministic sample fallback values.
</p>
)}
</div>
)}
{result.response && ( {result.response && (
<div> <div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label> <label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>

View File

@ -1,96 +1,82 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import apiClient from '../api/client'; import apiClient from '../api/client';
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']); function getMissingProviderFields(profile) {
const provider = profile?.provider || {};
function buildProfilePatchPayload(inputs = [], values = {}) { const missing = [];
const provider = {}; if (!provider.providerName) missing.push('providerName');
const profileInputValues = {}; if (!provider.senderId) missing.push('senderId');
if (!provider.dltEntityId) missing.push('dltEntityId');
inputs.forEach((input) => { return missing;
const rawValue = String(values[input.key] ?? '').trim();
if (!rawValue) return;
if (BASE_PROFILE_KEYS.has(input.key)) {
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
return;
}
profileInputValues[input.key] = rawValue;
});
return {
...(Object.keys(provider).length > 0 ? { provider } : {}),
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
};
}
function getInitialValues(inputs = []) {
return inputs.reduce((accumulator, input) => {
accumulator[input.key] = input.value || '';
return accumulator;
}, {});
} }
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) { export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
const [profile, setProfile] = useState(boundProfile); const [profile, setProfile] = useState(boundProfile);
const [profileForm, setProfileForm] = useState({}); const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
const [templateId, setTemplateId] = useState(''); const [templateId, setTemplateId] = useState('');
const [toNumber, setToNumber] = useState(''); const [toNumber, setToNumber] = useState('');
const [savingProfile, setSavingProfile] = useState(false); const [savingProvider, setSavingProvider] = useState(false);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [step, setStep] = useState('profile'); const [step, setStep] = useState('provider');
const missingInputs = useMemo(
() => profile?.executionReadiness?.missingProfileInputs || [],
[profile],
);
useEffect(() => { useEffect(() => {
setProfile(boundProfile); setProfile(boundProfile);
setProviderForm({
providerName: boundProfile?.provider?.providerName || '',
senderId: boundProfile?.provider?.senderId || '',
dltEntityId: boundProfile?.provider?.dltEntityId || '',
});
}, [boundProfile]); }, [boundProfile]);
useEffect(() => { const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]);
setProfileForm(getInitialValues(missingInputs));
}, [missingInputs]);
useEffect(() => { useEffect(() => {
if (!boundProfile) { if (!boundProfile) {
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.'); setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
setStep('profile'); setStep('provider');
return; return;
} }
setError(''); setError('');
setStep(missingInputs.length > 0 ? 'profile' : 'publish'); setStep(missingFields.length > 0 ? 'provider' : 'publish');
}, [boundProfile, missingInputs]); }, [boundProfile, missingFields]);
async function handleProfileSubmit(event) { async function handleProviderSubmit(e) {
event.preventDefault(); e.preventDefault();
if (!profile?.id || missingInputs.length === 0) return; if (!profile?.id) return;
setSavingProfile(true); setSavingProvider(true);
setError(''); setError('');
try { try {
const payload = buildProfilePatchPayload(missingInputs, profileForm);
const res = await apiClient.patch( const res = await apiClient.patch(
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`, `/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
payload, {
provider: {
providerName: providerForm.providerName,
senderId: providerForm.senderId.toUpperCase(),
dltEntityId: providerForm.dltEntityId,
},
}
); );
setProfile(res.data); setProfile(res.data);
setStep(res.data?.executionReadiness?.missingProfileInputs?.length > 0 ? 'profile' : 'publish'); setProviderForm({
providerName: res.data?.provider?.providerName || '',
senderId: res.data?.provider?.senderId || '',
dltEntityId: res.data?.provider?.dltEntityId || '',
});
setStep(getMissingProviderFields(res.data).length > 0 ? 'provider' : 'publish');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to save required profile fields'); setError(err.response?.data?.error || 'Failed to save provider details');
} finally { } finally {
setSavingProfile(false); setSavingProvider(false);
} }
} }
async function handlePublish(event) { async function handlePublish(e) {
event.preventDefault(); e.preventDefault();
if (!templateId.trim() || !toNumber.trim()) return; if (!templateId.trim() || !toNumber.trim()) return;
setPublishing(true); setPublishing(true);
@ -104,8 +90,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
await Promise.resolve(onSuccess()); await Promise.resolve(onSuccess());
} catch (err) { } catch (err) {
if (err.response?.data?.missingFields?.length) { if (err.response?.data?.missingFields?.length) {
setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`); setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
setStep('profile'); setStep('provider');
} else { } else {
setError(err.response?.data?.error || 'Failed to publish template'); setError(err.response?.data?.error || 'Failed to publish template');
} }
@ -117,101 +103,130 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
const isProfileMissing = !profile?.id; const isProfileMissing = !profile?.id;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-900/50 pb-10 pt-10 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10">
<div className="my-auto w-full max-w-md rounded-lg border border-border-main bg-surface-white p-5"> <div className="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-white"> <div className="w-12 h-12 rounded-full bg-white border border-gray-200 flex items-center justify-center mx-auto mb-4">
<span className="text-xl"></span> <span className="text-xl"></span>
</div> </div>
<h3 className="mb-1 text-center text-lg font-bold text-text-primary"> <h3 className="text-lg font-bold text-text-primary text-center mb-1">
{step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'} {step === 'provider' ? 'Complete Provider Details' : 'Publish Template'}
</h3> </h3>
<p className="mb-1 text-center text-sm text-text-muted"> <p className="text-sm text-text-muted text-center mb-1">
{step === 'profile' {step === 'provider'
? 'Complete the required fields on the bound cURL profile before publishing.' ? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.'
: 'Provide the DLT template ID and destination number to complete publish.'} : 'Provide the DLT template ID and destination number to complete publish.'}
</p> </p>
<p className="mb-2 text-center text-sm font-semibold capitalize text-text-primary"> <p className="text-sm font-semibold text-text-primary text-center mb-2 capitalize">
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')} {template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
</p> </p>
{profile && ( {profile && (
<p className="mb-6 text-center text-xs font-semibold uppercase tracking-wide text-text-muted"> <p className="text-xs text-text-muted text-center mb-6 uppercase tracking-wide font-semibold">
Bound Profile: {profile.name} Bound Profile: {profile.name}
</p> </p>
)} )}
{error && ( {error && (
<div className="mb-4 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text"> <div className="mb-4 px-4 py-2 rounded-md text-error-text bg-white border border-gray-200 text-sm font-medium">
{error} {error}
</div> </div>
)} )}
{step === 'profile' ? ( {step === 'provider' ? (
<form onSubmit={handleProfileSubmit} className="space-y-4"> <form onSubmit={handleProviderSubmit} className="space-y-4">
{missingInputs.map((input) => ( {missingFields.includes('providerName') && (
<div key={input.key}> <div>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label> <label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
<input <input
type={input.secret ? 'password' : 'text'} type="text"
value={profileForm[input.key] || ''} value={providerForm.providerName}
onChange={(event) => setProfileForm((current) => ({ onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
...current, 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 text-sm"
[input.key]: input.key === 'senderId' placeholder="e.g. MSG91"
? event.target.value.toUpperCase() autoFocus
: event.target.value, required
}))}
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue"
placeholder={input.label}
required={input.required !== false}
autoFocus={input.key === missingInputs[0]?.key}
/> />
</div> </div>
))} )}
{missingFields.includes('senderId') && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">Sender ID</label>
<input
type="text"
value={providerForm.senderId}
onChange={e => setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))}
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="6 CHARS"
maxLength={6}
required
/>
</div>
)}
{missingFields.includes('dltEntityId') && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Entity ID</label>
<input
type="text"
value={providerForm.dltEntityId}
onChange={e => setProviderForm(prev => ({ ...prev, dltEntityId: e.target.value }))}
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
placeholder="19-digit DLT PE ID"
required
/>
</div>
)}
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={savingProfile} disabled={savingProvider}
className="flex-1 rounded-lg border border-border-main py-2 text-sm font-medium text-text-primary transition hover:bg-page-bg disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={savingProfile || isProfileMissing || missingInputs.some((input) => !String(profileForm[input.key] || '').trim())} disabled={savingProvider || isProfileMissing || missingFields.some(field => {
className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50" if (field === 'providerName') return !providerForm.providerName.trim();
if (field === 'senderId') return !providerForm.senderId.trim();
if (field === 'dltEntityId') return !providerForm.dltEntityId.trim();
return false;
})}
className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{savingProfile ? 'Saving…' : 'Save Details'} {savingProvider ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Details'}
</button> </button>
</div> </div>
</form> </form>
) : ( ) : (
<form onSubmit={handlePublish} className="space-y-4"> <form onSubmit={handlePublish} className="space-y-4">
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">DLT Template ID</label> <label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label>
<input <input
type="text" type="text"
value={templateId} value={templateId}
onChange={(event) => setTemplateId(event.target.value)} onChange={e => setTemplateId(e.target.value)}
placeholder="e.g. 1234567890987654321" placeholder="e.g. 1234567890987654321"
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
autoFocus autoFocus
required required
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Destination Phone Number</label> <label className="block text-sm font-semibold text-text-primary mb-1.5">Destination Phone Number</label>
<input <input
type="text" type="text"
value={toNumber} value={toNumber}
onChange={(event) => setToNumber(event.target.value)} onChange={e => setToNumber(e.target.value)}
placeholder="e.g. 919876543210" placeholder="e.g. 919876543210"
className="w-full rounded-lg border border-border-main bg-page-bg px-4 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-blue" className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
required required
/> />
<p className="mt-1 text-xs text-text-muted">This sends the publish-triggering SMS request.</p> <p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p>
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
@ -219,16 +234,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={publishing} disabled={publishing}
className="flex-1 rounded-lg border border-border-main py-2 text-sm font-medium text-text-primary transition hover:bg-page-bg disabled:opacity-50" className="flex-1 py-2 rounded-lg border border-border-main text-text-primary hover:bg-page-bg text-sm font-medium transition disabled:opacity-50"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={publishing || !templateId.trim() || !toNumber.trim()} disabled={publishing || !templateId.trim() || !toNumber.trim()}
className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50" className="flex-1 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{publishing ? 'Publishing…' : 'Publish'} {publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing</> : 'Publish'}
</button> </button>
</div> </div>
</form> </form>

View File

@ -14,11 +14,11 @@ export function BusinessProvider({ children }) {
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false); const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => { const updateReadyState = useCallback((activeProfile, templates = []) => {
const hasProfile = !!activeProfile; const hasProfile = !!activeProfile;
const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride; setHasGlobalSms(hasProfile);
setHasGlobalSms(hasGlobalSmsProfiles); const p = activeProfile?.provider || {};
const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true; const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
setIsSetupComplete(nextIsSetupComplete); setIsSetupComplete(nextIsSetupComplete);
const nextHasSelectedTemplates = Array.isArray(templates) const nextHasSelectedTemplates = Array.isArray(templates)
? templates.some((template) => !!template?.selectedTemplate) ? templates.some((template) => !!template?.selectedTemplate)
@ -26,7 +26,7 @@ export function BusinessProvider({ children }) {
setHasSelectedTemplates(nextHasSelectedTemplates); setHasSelectedTemplates(nextHasSelectedTemplates);
return { return {
hasGlobalSms: hasGlobalSmsProfiles, hasGlobalSms: hasProfile,
isSetupComplete: nextIsSetupComplete, isSetupComplete: nextIsSetupComplete,
hasSelectedTemplates: nextHasSelectedTemplates, hasSelectedTemplates: nextHasSelectedTemplates,
}; };
@ -51,11 +51,7 @@ export function BusinessProvider({ children }) {
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })), apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
]); ]);
return updateReadyState( return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []);
smsRes.data?.activeProfile,
templatesRes.data?.templates || [],
smsRes.data?.hasProfiles === true,
);
}, [activeBusiness?.businessId, updateReadyState]); }, [activeBusiness?.businessId, updateReadyState]);
// On mount: rehydrate from sessionStorage and refresh from API // On mount: rehydrate from sessionStorage and refresh from API
@ -79,11 +75,7 @@ export function BusinessProvider({ children }) {
]), ]),
]); ]);
setActiveBusinessState(bizRes.data); setActiveBusinessState(bizRes.data);
updateReadyState( updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []);
smsRes[0].data?.activeProfile,
smsRes[1].data?.templates || [],
smsRes[0].data?.hasProfiles === true,
);
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ sessionStorage.setItem(SESSION_KEY, JSON.stringify({
businessId, businessId,
companyId: runtimeCompanyId || companyId || '', companyId: runtimeCompanyId || companyId || '',

View File

@ -1,833 +0,0 @@
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Link, useParams } from 'react-router-dom';
import apiClient from '../api/client';
const PAGE_SIZE = 5;
const CHART_WIDTH = 720;
const CHART_HEIGHT = 280;
const CHART_PADDING = { top: 18, right: 18, bottom: 34, left: 40 };
const STATUS_SCOPE_OPTIONS = [
{ value: 'all', label: 'All Events' },
{ value: 'live', label: 'Live' },
{ value: 'paused', label: 'Paused' },
{ value: 'pending', label: 'Pending' },
];
function formatNumber(value) {
return new Intl.NumberFormat().format(Number(value || 0));
}
function formatRate(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
return `${(value * 100).toFixed(1)}%`;
}
function formatFailureShare(triggeredCount, failedCount) {
const safeTriggeredCount = Number(triggeredCount || 0);
const safeFailedCount = Number(failedCount || 0);
if (safeTriggeredCount <= 0) return '—';
return `${((safeFailedCount / safeTriggeredCount) * 100).toFixed(1)}%`;
}
function formatLastTriggered(value) {
if (!value) return '—';
try {
return new Date(value).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
} catch {
return '—';
}
}
function titleCaseFromSlug(value) {
return String(value || '')
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function clampValue(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeSearchText(value) {
return String(value || '').trim().toLowerCase();
}
function buildLast30DaysSeries(rows = []) {
const rowByDate = new Map(
rows.map((row) => [String(row.date || ''), row])
);
const output = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let offset = 29; offset >= 0; offset -= 1) {
const date = new Date(today);
date.setDate(today.getDate() - offset);
const key = date.toISOString().slice(0, 10);
const row = rowByDate.get(key);
output.push({
key,
label: date.toLocaleDateString([], { month: 'short', day: 'numeric' }),
triggeredCount: Number(row?.triggeredCount || 0),
failedCount: Number(row?.failedCount || 0),
});
}
return output;
}
function getStatusAppearance(status) {
switch (status) {
case 'live':
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
case 'paused':
return 'border-slate-200 bg-slate-50 text-slate-600';
case 'pending':
return 'border-amber-200 bg-amber-50 text-amber-700';
case 'custom':
return 'border-violet-200 bg-violet-50 text-violet-700';
default:
return 'border-gray-200 bg-gray-50 text-gray-500';
}
}
function getScopeChipAppearance(isActive) {
return isActive
? 'border-primary-blue bg-primary-blue text-white shadow-sm'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50';
}
function buildPaginationState(value = {}) {
const pageSize = Number(value.pageSize || PAGE_SIZE) || PAGE_SIZE;
const totalItems = Number(value.totalItems || 0) || 0;
const totalPages = Math.max(1, Number(value.totalPages || Math.ceil(totalItems / pageSize) || 1));
return {
page: Math.max(1, Number(value.page || 1) || 1),
pageSize,
totalItems,
totalPages,
};
}
function StatCard({ title, value, subtitle, accentClassName }) {
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm transition duration-200 hover:-translate-y-1 hover:shadow-xl">
<div className={`mb-4 h-1.5 w-20 rounded-full ${accentClassName}`} />
<p className="text-sm font-semibold text-gray-500">{title}</p>
<p className="mt-3 text-4xl font-bold tracking-tight text-gray-900">{value}</p>
<p className="mt-3 text-sm text-gray-500">{subtitle}</p>
</div>
);
}
function AnalyticsTrendChart({ rows, hasFilters }) {
const innerWidth = CHART_WIDTH - CHART_PADDING.left - CHART_PADDING.right;
const innerHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
const svgRef = useRef(null);
const [hoverState, setHoverState] = useState(null);
const maxValue = Math.max(
1,
...rows.flatMap((row) => [row.triggeredCount, row.failedCount]),
);
const points = useMemo(
() => rows.map((row, index) => {
const x = CHART_PADDING.left + (rows.length === 1 ? innerWidth / 2 : (index / (rows.length - 1)) * innerWidth);
const triggeredY = CHART_PADDING.top + innerHeight - ((row.triggeredCount || 0) / maxValue) * innerHeight;
const failedY = CHART_PADDING.top + innerHeight - ((row.failedCount || 0) / maxValue) * innerHeight;
return {
...row,
x,
triggeredY,
failedY,
};
}),
[innerHeight, innerWidth, maxValue, rows],
);
const triggeredPoints = points.map((point) => `${point.x},${point.triggeredY}`).join(' ');
const failedPoints = points.map((point) => `${point.x},${point.failedY}`).join(' ');
const gridLines = Array.from({ length: 4 }, (_, index) => {
const ratio = index / 3;
const y = CHART_PADDING.top + innerHeight - ratio * innerHeight;
const label = Math.round(ratio * maxValue);
return { y, label };
});
const handlePointerMove = useCallback((event) => {
const chartElement = svgRef.current;
if (!chartElement || points.length === 0) return;
const rect = chartElement.getBoundingClientRect();
const relativeX = clampValue(event.clientX - rect.left, 0, rect.width);
const hoveredIndex = points.length === 1
? 0
: Math.round((relativeX / rect.width) * (points.length - 1));
const point = points[hoveredIndex];
if (!point) return;
const scaledX = (point.x / CHART_WIDTH) * rect.width;
const anchorY = Math.min(point.triggeredY, point.failedY);
const scaledY = (anchorY / CHART_HEIGHT) * rect.height;
const tooltipWidth = rect.width < 460 ? Math.max(164, rect.width - 24) : 208;
const tooltipHeight = 122;
const preferLeft = scaledX > rect.width * 0.62;
const left = preferLeft
? clampValue(scaledX - tooltipWidth - 18, 8, Math.max(8, rect.width - tooltipWidth - 8))
: clampValue(scaledX + 18, 8, Math.max(8, rect.width - tooltipWidth - 8));
const top = clampValue(scaledY - (tooltipHeight / 2), 8, Math.max(8, rect.height - tooltipHeight - 8));
setHoverState({
index: hoveredIndex,
left,
top,
width: tooltipWidth,
});
}, [points]);
const hoveredPoint = hoverState ? points[hoverState.index] : null;
return (
<div className="rounded-[28px] border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Trigger Volume, Last 30 Days</h2>
<p className="mt-1 text-sm text-gray-500">
{hasFilters ? 'Triggered vs failed SMS attempts for the current filtered view.' : 'Triggered vs failed SMS attempts.'}
</p>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-500">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-primary-blue" />
Triggered
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
Failed
</div>
</div>
</div>
<div className="relative overflow-visible">
<svg
ref={svgRef}
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="h-[280px] w-full touch-none sm:h-[320px]"
onPointerMove={handlePointerMove}
onPointerDown={handlePointerMove}
onPointerLeave={() => setHoverState(null)}
>
{gridLines.map((line) => (
<g key={line.y}>
<line
x1={CHART_PADDING.left}
y1={line.y}
x2={CHART_WIDTH - CHART_PADDING.right}
y2={line.y}
stroke="#E5E7EB"
strokeDasharray="4 6"
/>
<text
x={CHART_PADDING.left - 10}
y={line.y + 4}
textAnchor="end"
fontSize="11"
fill="#94A3B8"
>
{line.label}
</text>
</g>
))}
{hoveredPoint && (
<line
x1={hoveredPoint.x}
y1={CHART_PADDING.top}
x2={hoveredPoint.x}
y2={CHART_HEIGHT - CHART_PADDING.bottom}
stroke="#CBD5E1"
strokeDasharray="4 6"
/>
)}
<polyline
fill="none"
stroke="#3838C4"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
points={triggeredPoints}
/>
<polyline
fill="none"
stroke="#F87171"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
points={failedPoints}
/>
{points.map((point, index) => {
const showLabel = index % 5 === 0 || index === points.length - 1;
const isHovered = hoveredPoint?.key === point.key;
return (
<g key={point.key}>
<circle cx={point.x} cy={point.triggeredY} r={isHovered ? '6' : '3.5'} fill="#3838C4" />
<circle cx={point.x} cy={point.failedY} r={isHovered ? '6' : '3.5'} fill="#F87171" />
{showLabel && (
<text
x={point.x}
y={CHART_HEIGHT - 8}
textAnchor="middle"
fontSize="11"
fill="#94A3B8"
>
{point.label}
</text>
)}
</g>
);
})}
</svg>
{hoveredPoint && hoverState && (
<div
className="pointer-events-none absolute z-20 rounded-2xl border border-gray-200 bg-white/95 p-4 shadow-2xl backdrop-blur-sm"
style={{
left: `${hoverState.left}px`,
top: `${hoverState.top}px`,
width: `${hoverState.width}px`,
}}
>
<p className="text-sm font-semibold text-gray-900">{hoveredPoint.label}</p>
<div className="mt-3 space-y-2 text-sm text-gray-600">
<div className="flex items-center justify-between gap-3">
<span>Triggered</span>
<span className="font-semibold text-gray-900">{formatNumber(hoveredPoint.triggeredCount)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Failed</span>
<span className="font-semibold text-gray-900">{formatNumber(hoveredPoint.failedCount)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span>Failure Share</span>
<span className="font-semibold text-gray-900">
{formatFailureShare(hoveredPoint.triggeredCount, hoveredPoint.failedCount)}
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function Analytics() {
const { businessId } = useParams();
const searchRef = useRef(null);
const hasLoadedAnalyticsRef = useRef(false);
const [overview, setOverview] = useState(null);
const [eventRows, setEventRows] = useState([]);
const [allEventRows, setAllEventRows] = useState([]);
const [pagination, setPagination] = useState(buildPaginationState());
const [statusScope, setStatusScope] = useState('all');
const [selectedEventSlugs, setSelectedEventSlugs] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [page, setPage] = useState(1);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const selectedEventSet = useMemo(
() => new Set(selectedEventSlugs),
[selectedEventSlugs],
);
const eventOptionsBySlug = useMemo(
() => new Map(allEventRows.map((row) => [row.eventSlug, row])),
[allEventRows],
);
const selectedEventRows = useMemo(
() => selectedEventSlugs.map((slug) => (
eventOptionsBySlug.get(slug) || {
eventSlug: slug,
eventLabel: titleCaseFromSlug(slug),
status: 'not_configured',
statusLabel: 'Not Configured',
}
)),
[eventOptionsBySlug, selectedEventSlugs],
);
const suggestionRows = useMemo(() => {
const normalizedQuery = normalizeSearchText(deferredSearchQuery);
return allEventRows
.filter((row) => !selectedEventSet.has(row.eventSlug))
.filter((row) => statusScope === 'all' || row.status === statusScope)
.filter((row) => {
if (!normalizedQuery) return true;
return normalizeSearchText(row.eventLabel).includes(normalizedQuery)
|| normalizeSearchText(row.eventSlug).includes(normalizedQuery);
})
.slice(0, normalizedQuery ? 8 : 6);
}, [allEventRows, deferredSearchQuery, selectedEventSet, statusScope]);
const hasFilters = statusScope !== 'all' || selectedEventSlugs.length > 0;
const loadAnalytics = useCallback(async ({ background = false } = {}) => {
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
setError('');
}
const params = {
page,
pageSize: PAGE_SIZE,
};
if (statusScope !== 'all') {
params.statusScope = statusScope;
}
if (selectedEventSlugs.length > 0) {
params.eventSlugs = selectedEventSlugs.join(',');
}
try {
const [overviewRes, eventsRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/analytics/overview`, { params }),
apiClient.get(`/api/businesses/${businessId}/analytics/events`, { params }),
]);
setOverview(overviewRes.data);
setEventRows(eventsRes.data?.events || []);
setAllEventRows(eventsRes.data?.allEvents || eventsRes.data?.events || []);
setPagination(buildPaginationState(eventsRes.data?.pagination));
setError('');
} catch (err) {
setError(err.response?.data?.error || 'Failed to load analytics');
} finally {
if (background) {
setRefreshing(false);
} else {
hasLoadedAnalyticsRef.current = true;
setInitialLoading(false);
}
}
}, [businessId, page, selectedEventSlugs, statusScope]);
useEffect(() => {
loadAnalytics({ background: hasLoadedAnalyticsRef.current });
}, [loadAnalytics]);
useEffect(() => {
function handlePointerDown(event) {
if (!searchRef.current?.contains(event.target)) {
setSearchOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, []);
const chartRows = useMemo(
() => buildLast30DaysSeries(overview?.chart || []),
[overview?.chart],
);
const metrics = overview?.metrics || {};
const deliveryRateSubtitle = metrics.deliveryRateMode === 'send_fallback'
? 'Using send success until provider callbacks are connected'
: hasFilters
? 'Based on the currently selected event view'
: 'Based on delivery outcomes recorded so far';
const totalTriggerTitle = hasFilters ? 'Filtered Trigger Count' : 'Global Trigger Count';
const totalTriggerSubtitle = hasFilters
? 'All tracked executions in the current filtered view'
: 'All tracked event executions';
const triggeredTodaySubtitle = hasFilters
? 'Matching business events received today'
: 'Unique business events received today';
const activeEventsSubtitle = hasFilters
? `${formatNumber(metrics.totalEvents)} events in the current filtered view`
: `of ${formatNumber(metrics.totalEvents)} total events`;
const paginationStart = pagination.totalItems === 0 ? 0 : ((pagination.page - 1) * pagination.pageSize) + 1;
const paginationEnd = pagination.totalItems === 0
? 0
: Math.min(pagination.page * pagination.pageSize, pagination.totalItems);
function handleStatusScopeChange(nextScope) {
setStatusScope(nextScope);
setPage(1);
}
function handleSelectSuggestion(row) {
if (!row?.eventSlug || selectedEventSet.has(row.eventSlug)) return;
setSelectedEventSlugs((current) => [...current, row.eventSlug]);
setSearchQuery('');
setSearchOpen(false);
setPage(1);
}
function handleRemoveSelectedEvent(eventSlug) {
setSelectedEventSlugs((current) => current.filter((slug) => slug !== eventSlug));
setPage(1);
}
function handleClearAllFilters() {
setStatusScope('all');
setSelectedEventSlugs([]);
setSearchQuery('');
setSearchOpen(false);
setPage(1);
}
function handleSearchKeyDown(event) {
if (event.key === 'Escape') {
setSearchOpen(false);
return;
}
if (event.key === 'Enter' && suggestionRows.length > 0) {
event.preventDefault();
handleSelectSuggestion(suggestionRows[0]);
}
}
if (initialLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-primary-blue" />
</div>
);
}
if (error) {
return (
<div className="mx-auto max-w-6xl">
<div className="rounded-2xl border border-red-200 bg-red-50 px-5 py-4 text-sm font-medium text-red-700">
{error}
</div>
</div>
);
}
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="border-b border-gray-200 pb-5">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Analytics</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Updating view
</span>
)}
</div>
<p className="mt-1 text-sm font-medium text-gray-500">
Event trigger counts, operational health, and fallback delivery performance for this business.
</p>
</div>
<div className="rounded-[28px] border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div ref={searchRef} className="flex-1">
<label htmlFor="analytics-event-search" className="text-sm font-semibold text-gray-700">
Search And Select Events
</label>
<div className="relative mt-2">
<input
id="analytics-event-search"
type="text"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchOpen(true);
}}
onFocus={() => setSearchOpen(true)}
onKeyDown={handleSearchKeyDown}
placeholder="Search by event name or slug"
className="w-full rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 shadow-sm outline-none transition focus:border-primary-blue focus:ring-2 focus:ring-primary-blue/20"
/>
{searchOpen && (
<div className="absolute left-0 right-0 top-[calc(100%+0.6rem)] z-20 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
{suggestionRows.length === 0 ? (
<div className="px-4 py-4 text-sm text-gray-500">
No events matched your search or current status scope.
</div>
) : (
<ul className="max-h-72 overflow-y-auto py-2">
{suggestionRows.map((row) => (
<li key={row.eventSlug}>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelectSuggestion(row)}
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left transition hover:bg-gray-50"
>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900">{row.eventLabel}</div>
<div className="mt-1 truncate font-mono text-xs text-gray-400">{row.eventSlug}</div>
</div>
<span className={`shrink-0 rounded-full border px-3 py-1 text-[11px] font-semibold ${getStatusAppearance(row.status)}`}>
{row.statusLabel}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
<p className="mt-2 text-xs text-gray-400">
Select one or more events to refresh the cards, chart, and table for that exact view.
</p>
</div>
<div className="xl:w-[340px]">
<p className="text-sm font-semibold text-gray-700">Status Scope</p>
<div className="mt-2 flex flex-wrap gap-2">
{STATUS_SCOPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleStatusScopeChange(option.value)}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${getScopeChipAppearance(statusScope === option.value)}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
{selectedEventRows.length > 0 && (
<div className="mt-5 border-t border-gray-100 pt-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-500">Selected:</span>
{selectedEventRows.map((row) => (
<span
key={row.eventSlug}
className="inline-flex items-center gap-2 rounded-full border border-primary-blue/15 bg-primary-blue/10 px-3 py-1.5 text-sm font-medium text-primary-blue"
>
<span>{row.eventLabel}</span>
<button
type="button"
onClick={() => handleRemoveSelectedEvent(row.eventSlug)}
className="rounded-full text-primary-blue/80 transition hover:text-primary-blue"
aria-label={`Remove ${row.eventLabel}`}
>
×
</button>
</span>
))}
{(selectedEventRows.length > 0 || statusScope !== 'all') && (
<button
type="button"
onClick={handleClearAllFilters}
className="ml-auto text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
Clear all
</button>
)}
</div>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard
title="Events Triggered Today"
value={formatNumber(metrics.triggeredToday)}
subtitle={triggeredTodaySubtitle}
accentClassName="bg-primary-blue"
/>
<StatCard
title={totalTriggerTitle}
value={formatNumber(metrics.totalTriggered)}
subtitle={totalTriggerSubtitle}
accentClassName="bg-sky-500"
/>
<StatCard
title="Delivery Rate"
value={formatRate(metrics.deliveryRate)}
subtitle={deliveryRateSubtitle}
accentClassName="bg-emerald-500"
/>
<StatCard
title="Failed (24h)"
value={formatNumber(metrics.failedLast24Hours)}
subtitle="Send failures and failed delivery outcomes"
accentClassName="bg-red-500"
/>
<StatCard
title="Active Events"
value={formatNumber(metrics.activeEvents)}
subtitle={activeEventsSubtitle}
accentClassName="bg-slate-800"
/>
</div>
<AnalyticsTrendChart rows={chartRows} hasFilters={hasFilters} />
<div className="rounded-[28px] border border-gray-200 bg-white shadow-sm">
<div className="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Event Health</h2>
<p className="mt-1 text-sm text-gray-500">
{pagination.totalItems > 0
? `Showing ${paginationStart}-${paginationEnd} of ${formatNumber(pagination.totalItems)} events in this view`
: hasFilters
? 'No events match the current filter selection.'
: 'Per-event trigger counts and runtime status'}
</p>
</div>
<Link
to={`/${businessId}/events`}
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
View all events
</Link>
</div>
{eventRows.length === 0 ? (
<div className="px-6 py-10 text-center text-sm text-gray-500">
{hasFilters ? (
<div className="space-y-3">
<p>No events match the selected filters yet.</p>
<button
type="button"
onClick={handleClearAllFilters}
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
Clear filters
</button>
</div>
) : (
'No analytics have been recorded for this business yet.'
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-100">
<thead className="bg-gray-50">
<tr className="text-left text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">
<th className="px-6 py-4">Event</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Triggered Today</th>
<th className="px-6 py-4">Total Trigger Count</th>
<th className="px-6 py-4">Delivery Rate</th>
<th className="px-6 py-4">Last Triggered</th>
<th className="px-6 py-4 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{eventRows.map((row) => (
<tr key={row.eventSlug} className="hover:bg-gray-50/70">
<td className="px-6 py-4">
<div className="font-semibold text-gray-900">{row.eventLabel}</div>
<div className="mt-1 font-mono text-xs text-gray-400">{row.eventSlug}</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${getStatusAppearance(row.status)}`}>
{row.statusLabel}
</span>
</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
{formatNumber(row.triggeredToday)}
</td>
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
{formatNumber(row.totalTriggerCount)}
</td>
<td className="px-6 py-4">
<div className="text-sm font-semibold text-gray-900">{formatRate(row.deliveryRate)}</div>
<div className="mt-1 text-xs text-gray-400">
{row.deliveryRateMode === 'send_fallback'
? 'Send fallback'
: row.deliveryRateMode === 'callback'
? 'Callback-based'
: 'No data'}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatLastTriggered(row.lastTriggeredAt)}
</td>
<td className="px-6 py-4 text-right">
<Link
to={row.actionPath}
className="text-sm font-semibold text-link-blue transition hover:text-primary-blue"
>
Manage
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 border-t border-gray-100 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-500">
Page {formatNumber(pagination.page)} of {formatNumber(pagination.totalPages)}
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={pagination.page <= 1}
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Prev
</button>
<button
type="button"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((currentPage) => Math.min(pagination.totalPages, currentPage + 1))}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Next
</button>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
@ -157,15 +157,15 @@ 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 ${isScraped className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${isScraped
? 'bg-green-100 text-green-700' ? 'bg-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 ${isScraped ? 'bg-green-500' : 'bg-amber-500' className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
}`} }`}
/> />
{isScraped ? 'Onboarded' : 'Not Configured'} {isScraped ? 'Scraped' : 'Not Scraped Yet'}
</span> </span>
); );
} }
@ -193,54 +193,30 @@ function UnifiedBusinessCard({
const isImporting = !isScraped && creatingSalesChannelId === channelId; const isImporting = !isScraped && creatingSalesChannelId === channelId;
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId; const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl); const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
const isCardInteractive = isScraped ? Boolean(item.business) && !isOpening : !isImporting; const canOpenBusiness = isScraped && item.business && !isOpening;
const cardActionLabel = isScraped
? `Open ${name}`
: hasWebsiteUrl
? `Start onboarding for ${name}`
: `Use fallback URL for ${name}`;
function triggerCardAction() {
if (!isCardInteractive) return;
if (isScraped) {
if (!item.business) return;
onSelect(item.business);
return;
}
if (hasWebsiteUrl) {
onImport(item.channel);
return;
}
onFallback();
}
function handleCardClick() { function handleCardClick() {
triggerCardAction(); if (!canOpenBusiness) return;
onSelect(item.business);
} }
function handleCardKeyDown(event) { function handleCardKeyDown(event) {
if (!isCardInteractive) return; if (!canOpenBusiness) return;
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
triggerCardAction(); onSelect(item.business);
} }
} }
return ( return (
<div <div
className={`group rounded-lg bg-white border border-gray-200 transition-all overflow-hidden ${isCardInteractive 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'}`}
? 'cursor-pointer hover:border-primary-blue hover:shadow-sm'
: 'cursor-default'
}`}
onClick={handleCardClick} onClick={handleCardClick}
onKeyDown={handleCardKeyDown} onKeyDown={handleCardKeyDown}
role={isCardInteractive ? 'button' : undefined} role={isScraped ? 'button' : undefined}
tabIndex={isCardInteractive ? 0 : undefined} tabIndex={isScraped ? 0 : undefined}
aria-label={isCardInteractive ? cardActionLabel : 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">
@ -307,8 +283,7 @@ function UnifiedBusinessCard({
) : ( ) : (
<> <>
<button <button
onClick={(event) => { onClick={() => {
event.stopPropagation();
if (hasWebsiteUrl) { if (hasWebsiteUrl) {
onImport(item.channel); onImport(item.channel);
return; return;
@ -333,11 +308,9 @@ function UnifiedBusinessCard({
export default function Businesses() { export default function Businesses() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setActiveBusiness } = useBusiness(); const { setActiveBusiness } = useBusiness();
const hasLoadedBusinessesPageRef = useRef(false);
const [businesses, setBusinesses] = useState([]); const [businesses, setBusinesses] = useState([]);
const [salesChannels, setSalesChannels] = useState([]); const [salesChannels, setSalesChannels] = useState([]);
const [initialLoading, setInitialLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading'); const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
const [salesChannelQuery, setSalesChannelQuery] = useState(''); const [salesChannelQuery, setSalesChannelQuery] = useState('');
const [selectingBusinessId, setSelectingBusinessId] = useState(''); const [selectingBusinessId, setSelectingBusinessId] = useState('');
@ -418,55 +391,37 @@ export default function Businesses() {
setBusinesses(res.data.businesses || []); setBusinesses(res.data.businesses || []);
}, []); }, []);
const loadSalesChannels = useCallback(async ({ background = false } = {}) => { const loadSalesChannels = useCallback(async () => {
if (!background) { setSalesChannelsStatus('loading');
setSalesChannelsStatus('loading');
}
const channels = await fetchActiveSalesChannels(); const channels = await fetchActiveSalesChannels();
setSalesChannels(channels); setSalesChannels(channels);
setSalesChannelsStatus('success'); setSalesChannelsStatus('success');
}, []); }, []);
const load = useCallback(async ({ background = false } = {}) => { const load = useCallback(async () => {
if (background) { setLoading(true);
setRefreshing(true); setError('');
} else {
setInitialLoading(true);
setError('');
}
try { try {
const [businessesRes, salesChannelsRes] = await Promise.allSettled([ const [businessesRes, salesChannelsRes] = await Promise.allSettled([
loadBusinesses(), loadBusinesses(),
loadSalesChannels({ background }), loadSalesChannels(),
]); ]);
if (businessesRes.status === 'rejected') { if (businessesRes.status === 'rejected') {
setError('Failed to load businesses'); setError('Failed to load businesses');
} else {
setError('');
} }
if (salesChannelsRes.status === 'rejected') { if (salesChannelsRes.status === 'rejected') {
if (!background) { setSalesChannels([]);
setSalesChannels([]); setSalesChannelsStatus('error');
setSalesChannelsStatus('error');
}
if (businessesRes.status !== 'rejected') {
setError('Failed to refresh sales channels');
}
} }
} finally { } finally {
if (background) { setLoading(false);
setRefreshing(false);
} else {
hasLoadedBusinessesPageRef.current = true;
setInitialLoading(false);
}
} }
}, [loadBusinesses, loadSalesChannels]); }, [loadBusinesses, loadSalesChannels]);
useEffect(() => { load({ background: hasLoadedBusinessesPageRef.current }); }, [load]); useEffect(() => { load(); }, [load]);
const handleBusinessCreated = useCallback(async (created) => { const handleBusinessCreated = useCallback(async (created) => {
setShowModal(false); setShowModal(false);
@ -474,11 +429,12 @@ export default function Businesses() {
setCreatedBusiness(created); setCreatedBusiness(created);
try { try {
await load({ background: true }); await Promise.all([loadBusinesses(), loadSalesChannels()]);
setSalesChannelsStatus('success');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.'); setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
} }
}, [load]); }, [loadBusinesses, loadSalesChannels]);
const handleBusinessJobStarted = useCallback(async (job) => { const handleBusinessJobStarted = useCallback(async (job) => {
setError(''); setError('');
@ -590,7 +546,7 @@ export default function Businesses() {
try { try {
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`); await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
setDeleteTarget(null); setDeleteTarget(null);
await load({ background: true }); await load();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to delete business'); setError(err.response?.data?.error || 'Failed to delete business');
} finally { } finally {
@ -619,7 +575,7 @@ export default function Businesses() {
} }
} }
if (initialLoading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <div className="min-h-screen flex items-center justify-center bg-white">
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
@ -632,17 +588,9 @@ export default function Businesses() {
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div> <div>
<div className="flex flex-wrap items-center gap-3"> <h1 className="text-2xl font-bold text-gray-800 tracking-tight">
<h1 className="text-2xl font-bold text-gray-800 tracking-tight"> {showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')} </h1>
</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing list
</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{showUnifiedSalesChannelView {showUnifiedSalesChannelView
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.' ? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
@ -759,7 +707,7 @@ export default function Businesses() {
{showModal && ( {showModal && (
<RegisterBusinessModal <RegisterBusinessModal
onClose={() => { setShowModal(false); load({ background: true }); }} onClose={() => { setShowModal(false); load(); }}
onJobStarted={handleBusinessJobStarted} onJobStarted={handleBusinessJobStarted}
/> />
)} )}

View File

@ -19,67 +19,90 @@ const SUPPORTED_DLT_VARIABLE_OPTIONS = DLT_VARIABLE_OPTIONS;
const DLT_TOKEN_SET = new Set(SUPPORTED_DLT_VARIABLE_OPTIONS.map((option) => option.token)); const DLT_TOKEN_SET = new Set(SUPPORTED_DLT_VARIABLE_OPTIONS.map((option) => option.token));
const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g; const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g; const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
const ORDER_PAYMENT_EVENT_SLUGS = [ const DELIVERY_EVENT_SLUGS = new Set([
'placed', 'out_for_pickup',
'payment_failed', 'bag_picked',
]; 'bag_reached_drop_point',
const DELIVERY_EVENT_SLUGS = [ 'in_transit',
'out_for_delivery', 'out_for_delivery',
'delivery_attempt_failed', 'delivery_attempt_failed',
'delivery_done', 'delivery_done',
]; 'handed_over_to_customer',
const CANCELLATION_EVENT_SLUGS = [ 'bag_lost',
]);
const CANCELLATION_EVENT_SLUGS = new Set([
'bag_not_confirmed',
'cancelled_at_dp', 'cancelled_at_dp',
'cancelled_customer', 'cancelled_customer',
'cancelled_failed_at_dp',
'cancelled_fynd',
'rejected_by_customer', 'rejected_by_customer',
]; ]);
const RETURN_EVENT_SLUGS = [ const REFUND_EVENT_SLUGS = new Set([
'return_initiated', 'credit_note_generated',
'return_bag_picked', 'partial_refund_completed',
'return_bag_delivered', 'refund_acknowledged',
]; 'refund_approved',
const REFUND_EVENT_SLUGS = [
'refund_completed', 'refund_completed',
'refund_failed', 'refund_failed',
'refund_initiated', 'refund_initiated',
]; 'refund_on_hold',
const CUSTOMER_EVENT_SECTIONS = [ 'refund_pending',
'refund_pending_for_approval',
'refund_retry',
]);
const RETURN_EVENT_SLUGS = new Set([
'assigning_return_dp',
'internal_return_dp_reassign',
'deadstock_defective',
'deadstock_defective_lost',
]);
const EVENT_GROUPS = [
{ {
id: 'order_payment', id: 'fulfillment',
label: 'Order & Payment', label: 'Order & Fulfillment',
description: 'Core order confirmation and critical payment updates customers genuinely care about.', description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
slugs: ORDER_PAYMENT_EVENT_SLUGS, defaultExpanded: false,
}, },
{ {
id: 'delivery', id: 'delivery',
label: 'Delivery Journey', label: 'Delivery Journey',
description: 'The moments that matter most once an order is close to the doorstep.', description: 'Courier pickup, in-transit updates, and final handover milestones.',
slugs: DELIVERY_EVENT_SLUGS, defaultExpanded: false,
}, },
{ {
id: 'cancellations', id: 'cancellations',
label: 'Cancellations & Rejections', label: 'Cancellations & Rejections',
description: 'Critical order-stop events that customers should be notified about immediately.', description: 'Customer, merchant, and delivery-partner driven cancellations and rejections.',
slugs: CANCELLATION_EVENT_SLUGS, defaultExpanded: false,
}, },
{ {
id: 'returns_refunds', id: 'returns',
label: 'Returns & Refunds', label: 'Returns',
description: 'Only the key return and refund milestones worth notifying customers about.', description: 'Return initiation, pickup, transit, and merchant-side return handling.',
slugs: [...RETURN_EVENT_SLUGS, ...REFUND_EVENT_SLUGS], defaultExpanded: false,
},
{
id: 'refunds',
label: 'Refunds',
description: 'Refund processing and credit-note states across payment flows.',
defaultExpanded: false,
},
{
id: 'rto',
label: 'RTO',
description: 'Return-to-origin movement and completion states after failed delivery.',
defaultExpanded: false,
}, },
{ {
id: 'custom', id: 'custom',
label: 'Custom Events', label: 'Custom Events',
description: 'Business-specific events you added manually for your own messaging flows.', description: 'Business-specific events you added manually for your own messaging flows.',
slugs: [], defaultExpanded: false,
}, },
]; ];
const CUSTOMER_EVENT_SECTION_BY_SLUG = new Map( const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
CUSTOMER_EVENT_SECTIONS.flatMap((section) => section.slugs.map((slug) => [slug, section.id])), acc[group.id] = group.defaultExpanded;
);
const CUSTOMER_EVENT_SECTION_ORDER = CUSTOMER_EVENT_SECTIONS.reduce((acc, section) => {
acc[section.id] = new Map(section.slugs.map((slug, index) => [slug, index]));
return acc; return acc;
}, {}); }, {});
const EVENT_TEMPLATE_STATUS_CONFIG = { const EVENT_TEMPLATE_STATUS_CONFIG = {
@ -100,6 +123,37 @@ const EVENT_TEMPLATE_STATUS_CONFIG = {
}, },
}; };
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) { function normalizeTemplateStatus(status) {
return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting'; return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting';
} }
@ -116,19 +170,20 @@ function buildSelectedTemplatePreview(template = {}) {
variableMap: template?.variableMap && typeof template.variableMap === 'object' variableMap: template?.variableMap && typeof template.variableMap === 'object'
? template.variableMap ? template.variableMap
: {}, : {},
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
? template.executionMeta
: {},
curlProfileId: String(template?.curlProfileId || '').trim(), curlProfileId: String(template?.curlProfileId || '').trim(),
}; };
} }
function getCustomerFacingSectionId(event) { function getEventGroupId(event) {
const slug = String(event?.slug || ''); const slug = String(event?.slug || '');
if (!event?.isDefault) return 'custom'; if (!event?.isDefault) return 'custom';
return CUSTOMER_EVENT_SECTION_BY_SLUG.get(slug) || null; if (slug.startsWith('rto_') || slug === 'return_to_origin') return 'rto';
if (slug.startsWith('return_') || RETURN_EVENT_SLUGS.has(slug)) return 'returns';
if (slug.startsWith('refund_') || REFUND_EVENT_SLUGS.has(slug)) return 'refunds';
if (CANCELLATION_EVENT_SLUGS.has(slug)) return 'cancellations';
if (DELIVERY_EVENT_SLUGS.has(slug)) return 'delivery';
return 'fulfillment';
} }
function matchesEventSearch(event, searchTerm) { function matchesEventSearch(event, searchTerm) {
@ -140,33 +195,11 @@ function matchesEventSearch(event, searchTerm) {
.some((value) => String(value).toLowerCase().includes(query)); .some((value) => String(value).toLowerCase().includes(query));
} }
function sortSectionEvents(sectionId, events) { function buildGroupedEvents(events, searchTerm) {
if (sectionId === 'custom') { return EVENT_GROUPS.map((group) => ({
return [...events].sort((left, right) => String(left?.label || '').localeCompare(String(right?.label || ''))); ...group,
} events: events.filter((event) => getEventGroupId(event) === group.id && matchesEventSearch(event, searchTerm)),
})).filter((group) => group.events.length > 0);
const orderMap = CUSTOMER_EVENT_SECTION_ORDER[sectionId] || new Map();
return [...events].sort((left, right) => {
const leftRank = orderMap.get(String(left?.slug || '')) ?? Number.MAX_SAFE_INTEGER;
const rightRank = orderMap.get(String(right?.slug || '')) ?? Number.MAX_SAFE_INTEGER;
if (leftRank !== rightRank) return leftRank - rightRank;
return String(left?.label || '').localeCompare(String(right?.label || ''));
});
}
function buildVisibleEventSections(events, searchTerm) {
return CUSTOMER_EVENT_SECTIONS.map((section) => {
const filteredEvents = events.filter((event) => (
getCustomerFacingSectionId(event) === section.id
&& matchesEventSearch(event, searchTerm)
));
return {
...section,
events: sortSectionEvents(section.id, filteredEvents),
};
}).filter((section) => section.events.length > 0);
} }
function getVariantKey(slug, index) { function getVariantKey(slug, index) {
@ -209,7 +242,6 @@ function createVariantDraft(text = '') {
currentText: text, currentText: text,
validationStatus: 'idle', validationStatus: 'idle',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}; };
} }
@ -567,20 +599,9 @@ function TemplateGenerationWorkspaceModal({
</div> </div>
)} )}
{validationStatus === 'rejected' && currentMatchesCheckedText && (activeDraft?.issues?.length > 0 || activeDraft?.why) && ( {validationStatus === 'rejected' && currentMatchesCheckedText && activeDraft?.why && (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700"> <div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
<p className="font-semibold">Why it did not pass:</p> <span className="font-semibold">Why it did not pass:</span> {activeDraft.why}
{activeDraft?.issues?.length > 0 ? (
<ul className="mt-2 list-disc space-y-1 pl-5">
{activeDraft.issues.map((issue, index) => (
<li key={`${issue.code || 'issue'}-${index}`}>
{issue.message}
</li>
))}
</ul>
) : (
<p className="mt-2">{activeDraft.why}</p>
)}
</div> </div>
)} )}
</div> </div>
@ -657,14 +678,13 @@ function TemplateGenerationWorkspaceModal({
export default function Events() { export default function Events() {
const { businessId } = useParams(); const { businessId } = useParams();
const { refreshOnboardingState } = useBusiness(); const { refreshOnboardingState } = useBusiness();
const hasLoadedEventsRef = useRef(false);
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [initialLoading, setInitialLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [addingEvent, setAddingEvent] = useState(false); const [addingEvent, setAddingEvent] = useState(false);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
const [genState, setGenState] = useState({}); const [genState, setGenState] = useState({});
const [variants, setVariants] = useState({}); const [variants, setVariants] = useState({});
const [variantDrafts, setVariantDrafts] = useState({}); const [variantDrafts, setVariantDrafts] = useState({});
@ -733,12 +753,8 @@ export default function Events() {
}; };
}, [templateWorkspace.slug]); }, [templateWorkspace.slug]);
const loadEvents = useCallback(async ({ background = false } = {}) => { const loadEvents = useCallback(async () => {
if (background) { setLoading(true);
setRefreshing(true);
} else {
setInitialLoading(true);
}
try { try {
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([ const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/events`), apiClient.get(`/api/businesses/${businessId}/events`),
@ -755,27 +771,21 @@ export default function Events() {
} = buildTemplateUiState(templates); } = buildTemplateUiState(templates);
setEvents(eventsRes.data.events || []); setEvents(eventsRes.data.events || []);
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl); setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
setVariants(nextVariants); setVariants(nextVariants);
setGenState(nextGenState); setGenState(nextGenState);
setTemplateStatusBySlug(nextTemplateStatusBySlug); setTemplateStatusBySlug(nextTemplateStatusBySlug);
setSelectedTemplateBySlug(nextSelectedTemplateBySlug); setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants)); setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
setError('');
} catch { } catch {
setError('Failed to load events'); setError('Failed to load events');
} finally { } finally {
if (background) { setLoading(false);
setRefreshing(false);
} else {
hasLoadedEventsRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId]); }, [businessId]);
useEffect(() => { useEffect(() => {
loadEvents({ background: hasLoadedEventsRef.current }); loadEvents();
}, [loadEvents]); }, [loadEvents]);
async function handleAddEvent(e) { async function handleAddEvent(e) {
@ -787,7 +797,7 @@ export default function Events() {
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() }); await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
setNewLabel(''); setNewLabel('');
setShowAddForm(false); setShowAddForm(false);
await loadEvents({ background: true }); await loadEvents();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to add event'); setError(err.response?.data?.error || 'Failed to add event');
} finally { } finally {
@ -798,7 +808,7 @@ export default function Events() {
async function handleDelete(slug) { async function handleDelete(slug) {
try { try {
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`); await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
await loadEvents({ background: true }); await loadEvents();
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to delete event'); setError(err.response?.data?.error || 'Failed to delete event');
} }
@ -1061,7 +1071,6 @@ export default function Events() {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: 'checking', validationStatus: 'checking',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}, },
})); }));
@ -1072,24 +1081,12 @@ export default function Events() {
editedTemplate, editedTemplate,
}); });
const issues = Array.isArray(res.data?.issues)
? res.data.issues
.filter((issue) => issue && typeof issue === 'object')
.map((issue) => ({
code: String(issue.code || '').trim(),
message: String(issue.message || '').trim(),
evidence: String(issue.evidence || '').trim(),
}))
.filter((issue) => issue.message)
: [];
setVariantDrafts((currentDrafts) => ({ setVariantDrafts((currentDrafts) => ({
...currentDrafts, ...currentDrafts,
[draftKey]: { [draftKey]: {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: res.data?.approved ? 'approved' : 'rejected', validationStatus: res.data?.approved ? 'approved' : 'rejected',
why: String(res.data?.why || issues[0]?.message || ''), why: res.data?.why || '',
issues,
lastCheckedText: editedTemplate, lastCheckedText: editedTemplate,
}, },
})); }));
@ -1101,7 +1098,6 @@ export default function Events() {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)), ...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: 'idle', validationStatus: 'idle',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}, },
})); }));
@ -1156,7 +1152,6 @@ export default function Events() {
currentText: nextText, currentText: nextText,
validationStatus: 'idle', validationStatus: 'idle',
why: '', why: '',
issues: [],
lastCheckedText: '', lastCheckedText: '',
}, },
})); }));
@ -1221,7 +1216,14 @@ export default function Events() {
handleGenerate(slug, { sessionId }); handleGenerate(slug, { sessionId });
} }
if (initialLoading) { function toggleGroup(groupId) {
setExpandedGroups((current) => ({
...current,
[groupId]: !current[groupId],
}));
}
if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
@ -1229,8 +1231,8 @@ export default function Events() {
); );
} }
const eventSections = buildVisibleEventSections(events, searchTerm); const groupedEvents = buildGroupedEvents(events, searchTerm);
const totalVisibleEvents = eventSections.reduce((count, section) => count + section.events.length, 0); const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.events.length, 0);
const workspaceSlug = templateWorkspace.slug; const workspaceSlug = templateWorkspace.slug;
const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null; const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null;
const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : []; const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : [];
@ -1257,16 +1259,8 @@ export default function Events() {
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200"> <div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
<div> <div>
<div className="flex flex-wrap items-center gap-3"> <h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1> <p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Updating events
</span>
)}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for customer-facing lifecycle events.</p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md"> <div className="relative flex-1 sm:max-w-md">
@ -1342,106 +1336,133 @@ export default function Events() {
</form> </form>
)} )}
{eventSections.length === 0 ? ( {groupedEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center "> <div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center ">
<p className="text-base font-semibold text-gray-800">No events match your search.</p> <p className="text-base font-semibold text-gray-800">No events match your search.</p>
<p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the customer-facing lifecycle list.</p> <p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the full lifecycle list.</p>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-4">
{eventSections.map((section) => ( {groupedEvents.map((group) => {
<section key={section.id} className="space-y-3"> const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
<div className="px-1"> const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-800">{section.label}</h2>
<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">
{section.events.length} events
</span>
</div>
<p className="mt-1 text-sm font-medium text-gray-500">{section.description}</p>
</div>
<div className="space-y-4"> return (
{section.events.map((event) => { <section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
const state = genState[event.slug] || 'idle'; <button
const eventVariants = variants[event.slug] || []; type="button"
const templateStatus = templateStatusBySlug[event.slug] || 'unselected'; onClick={() => toggleGroup(group.id)}
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected; className="group flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50"
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null; >
const hasSelectedTemplate = !!selectedTemplatePreview; <div className="flex min-w-0 items-start gap-4">
const hasDraftWorkspace = eventVariants.length > 0; <div className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${groupStyle.markerShell}`}>
const canOpenGenerationWorkspace = hasDraftWorkspace; <span className={`h-2.5 w-2.5 rounded-full ${groupStyle.markerDot}`} />
const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace;
return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
{event.isDefault ? (
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
) : (
<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 flex-wrap items-center gap-2 sm:justify-end">
<span
title={statusConfig.label}
aria-label={statusConfig.label}
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
>
{statusConfig.label}
</span>
<button
type="button"
onClick={() => {
if (hasSelectedTemplate) {
handleOpenTemplateViewer(event.slug);
return;
}
if (canOpenGenerationWorkspace) {
handleOpenTemplateWorkspace(event.slug);
return;
}
handleOpenGenerateWorkspace(event.slug);
}}
disabled={state === 'loading' || (!hasSelectedTemplate && !canOpenGenerationWorkspace && !readyToGenerate)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasExistingWorkspace
? '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</>
) : hasSelectedTemplate ? (
<>View Template</>
) : canOpenGenerationWorkspace ? (
<>Open Drafts</>
) : (
<>Generate Template</>
)}
</button>
</div>
</div>
</div> </div>
); <div className="min-w-0">
})} <div className="flex flex-wrap items-center gap-2">
</div> <h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
</section> <span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
))} {group.events.length} events
</span>
</div>
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
</div>
</div>
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 text-gray-600 shadow-sm transition group-hover:border-gray-300 group-hover:bg-white group-hover:text-gray-800 ${isExpanded ? 'rotate-180' : ''
}`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
</svg>
</span>
</button>
{isExpanded && (
<div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
<div className="space-y-4">
{group.events.map((event) => {
const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || [];
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
const hasSelectedTemplate = !!selectedTemplatePreview;
const hasDraftWorkspace = eventVariants.length > 0;
const canOpenGenerationWorkspace = hasDraftWorkspace;
const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace;
return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
{event.isDefault ? (
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
) : (
<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 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>
<button
type="button"
onClick={() => {
if (hasSelectedTemplate) {
handleOpenTemplateViewer(event.slug);
return;
}
if (canOpenGenerationWorkspace) {
handleOpenTemplateWorkspace(event.slug);
return;
}
handleOpenGenerateWorkspace(event.slug);
}}
disabled={state === 'loading' || (!hasSelectedTemplate && !canOpenGenerationWorkspace && !readyToGenerate)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasExistingWorkspace
? '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</>
) : hasSelectedTemplate ? (
<>View Template</>
) : canOpenGenerationWorkspace ? (
<>Open Drafts</>
) : (
<>Generate Template</>
)}
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</section>
);
})}
</div> </div>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -82,11 +82,9 @@ function getTemplateSortRank(template) {
export default function Templates() { export default function Templates() {
const { businessId } = useParams(); const { businessId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hasLoadedTemplatesRef = useRef(false);
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [profilesById, setProfilesById] = useState({}); const [profilesById, setProfilesById] = useState({});
const [initialLoading, setInitialLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [whitelistTarget, setWhitelistTarget] = useState(null); const [whitelistTarget, setWhitelistTarget] = useState(null);
const [testTarget, setTestTarget] = useState(null); const [testTarget, setTestTarget] = useState(null);
@ -98,13 +96,9 @@ export default function Templates() {
const highlightTimeoutRef = useRef(null); const highlightTimeoutRef = useRef(null);
const handledFocusSlugRef = useRef(''); const handledFocusSlugRef = useRef('');
const loadTemplates = useCallback(async ({ background = false } = {}) => { const loadTemplates = useCallback(async () => {
if (background) { setLoading(true);
setRefreshing(true); setError('');
} else {
setInitialLoading(true);
setError('');
}
try { try {
const [templatesRes, profilesRes] = await Promise.all([ const [templatesRes, profilesRes] = await Promise.all([
@ -117,21 +111,15 @@ export default function Templates() {
setTemplates(allTemplates); setTemplates(allTemplates);
setProfilesById(profileMap); setProfilesById(profileMap);
setError('');
} catch { } catch {
setError('Failed to load templates'); setError('Failed to load templates');
} finally { } finally {
if (background) { setLoading(false);
setRefreshing(false);
} else {
hasLoadedTemplatesRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId]); }, [businessId]);
useEffect(() => { useEffect(() => {
loadTemplates({ background: hasLoadedTemplatesRef.current }); loadTemplates();
}, [loadTemplates]); }, [loadTemplates]);
useEffect(() => () => { useEffect(() => () => {
@ -191,7 +179,7 @@ export default function Templates() {
async function handleWhitelistSuccess() { async function handleWhitelistSuccess() {
setWhitelistTarget(null); setWhitelistTarget(null);
await loadTemplates({ background: true }); await loadTemplates();
} }
async function handleRuntimeToggle(template) { async function handleRuntimeToggle(template) {
@ -219,7 +207,7 @@ export default function Templates() {
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null ? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
: null; : null;
if (initialLoading) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" /> <div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" />
@ -230,15 +218,7 @@ export default function Templates() {
return ( return (
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<div className="mb-6 border-b border-gray-200 pb-5"> <div className="mb-6 border-b border-gray-200 pb-5">
<div className="flex flex-wrap items-center gap-3"> <h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing templates
</span>
)}
</div>
<p className="mt-1 text-sm font-medium text-gray-500"> <p className="mt-1 text-sm font-medium text-gray-500">
Manage template runtime, whitelisting, and testing from one place. Manage template runtime, whitelisting, and testing from one place.
</p> </p>
@ -298,7 +278,7 @@ export default function Templates() {
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
{visibleTemplates.map((template) => { {visibleTemplates.map((template) => {
const appearance = getCardAppearance(template); const appearance = getCardAppearance(template);
const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null; const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null;
@ -317,26 +297,26 @@ export default function Templates() {
delete templateCardRefs.current[template.eventSlug]; delete templateCardRefs.current[template.eventSlug];
} }
}} }}
className={`overflow-hidden rounded-[22px] border border-gray-200 border-l-4 bg-white px-4 py-3.5 shadow-[0_8px_22px_rgba(15,23,42,0.05)] transition-all duration-300 ${appearance.accentClassName} ${ className={`overflow-hidden rounded-[28px] border border-gray-200 border-l-4 bg-white px-6 py-5 shadow-[0_12px_30px_rgba(15,23,42,0.06)] transition-all duration-300 ${appearance.accentClassName} ${
highlightedEventSlug === template.eventSlug highlightedEventSlug === template.eventSlug
? 'ring-2 ring-primary-blue/30' ? 'ring-2 ring-primary-blue/30'
: 'hover:-translate-y-0.5 hover:shadow-[0_12px_24px_rgba(15,23,42,0.07)]' : 'hover:-translate-y-0.5 hover:shadow-[0_16px_34px_rgba(15,23,42,0.08)]'
}`} }`}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-4">
<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">
<h3 className="text-base font-semibold tracking-tight text-gray-900"> <h3 className="text-[1.35rem] font-semibold tracking-tight text-gray-900">
{getTemplateDisplayName(template)} {getTemplateDisplayName(template)}
</h3> </h3>
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold ${appearance.pillClassName}`}> <span className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold ${appearance.pillClassName}`}>
{isPublished && ( {isPublished && (
<span className={`h-2 w-2 rounded-full ${isRuntimeEnabled ? 'bg-current opacity-80' : 'bg-current opacity-55'}`} /> <span className={`h-2.5 w-2.5 rounded-full ${isRuntimeEnabled ? 'bg-current opacity-80' : 'bg-current opacity-55'}`} />
)} )}
{appearance.pillLabel} {appearance.pillLabel}
</span> </span>
</div> </div>
<p className="mt-1.5 max-w-[28ch] text-[12.5px] leading-5 text-gray-400"> <p className="mt-3 max-w-[34ch] text-sm leading-7 text-gray-400">
{appearance.description} {appearance.description}
</p> </p>
</div> </div>
@ -348,38 +328,38 @@ export default function Templates() {
aria-label={`Set runtime ${isRuntimeEnabled ? 'paused' : 'active'} for ${getTemplateDisplayName(template)}`} aria-label={`Set runtime ${isRuntimeEnabled ? 'paused' : 'active'} for ${getTemplateDisplayName(template)}`}
disabled={!isPublished || isRuntimeUpdating} disabled={!isPublished || isRuntimeUpdating}
onClick={() => isPublished && handleRuntimeToggle(template)} onClick={() => isPublished && handleRuntimeToggle(template)}
className={`relative inline-flex h-6 w-10 items-center rounded-full border transition ${ className={`relative inline-flex h-7 w-12 items-center rounded-full border transition ${
isPublished isPublished
? `${appearance.switchTrackClassName} ${isRuntimeUpdating ? 'cursor-wait opacity-70' : 'cursor-pointer'}` ? `${appearance.switchTrackClassName} ${isRuntimeUpdating ? 'cursor-wait opacity-70' : 'cursor-pointer'}`
: 'cursor-not-allowed border-[#d8dee8] bg-[#eef1f5] opacity-95' : 'cursor-not-allowed border-[#d8dee8] bg-[#eef1f5] opacity-95'
}`} }`}
> >
<span <span
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition ${ className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
isPublished && isRuntimeEnabled ? 'translate-x-5' : 'translate-x-1' isPublished && isRuntimeEnabled ? 'translate-x-6' : 'translate-x-1'
}`} }`}
/> />
</button> </button>
</div> </div>
<div className="mt-3 border-t border-gray-100 pt-3"> <div className="mt-5 border-t border-gray-100 pt-5">
<div className="flex items-end justify-between gap-3"> <div className="flex items-end justify-between gap-4">
<div className="flex flex-wrap items-start gap-4"> <div className="flex flex-wrap items-start gap-8">
<div> <div>
<p className="text-[11px] font-medium text-gray-400">Profile</p> <p className="text-xs font-medium text-gray-400">Profile</p>
<p className="mt-0.5 text-[13px] font-semibold text-gray-900"> <p className="mt-1 text-base font-semibold text-gray-900">
{getBoundProfileSummary(template, boundProfile)} {getBoundProfileSummary(template, boundProfile)}
</p> </p>
</div> </div>
<div> <div>
<p className="text-[11px] font-medium text-gray-400">DLT Template ID</p> <p className="text-xs font-medium text-gray-400">DLT Template ID</p>
<p className="mt-0.5 font-mono text-[11px] font-semibold text-gray-900"> <p className="mt-1 font-mono text-sm font-semibold text-gray-900">
{formatDltTemplateId(template.templateId)} {formatDltTemplateId(template.templateId)}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-3">
<button <button
type="button" type="button"
onClick={() => setWorkspaceSlug(template.eventSlug)} onClick={() => setWorkspaceSlug(template.eventSlug)}
@ -392,7 +372,7 @@ export default function Templates() {
<button <button
type="button" type="button"
onClick={() => setWhitelistTarget(template)} onClick={() => setWhitelistTarget(template)}
className="rounded-full border border-orange-200 bg-[#fff4ea] px-3 py-1 text-[13px] font-semibold text-orange-700 transition hover:border-orange-300 hover:bg-[#ffeddc]" className="rounded-full border border-orange-200 bg-[#fff4ea] px-4 py-2 text-sm font-semibold text-orange-700 transition hover:border-orange-300 hover:bg-[#ffeddc]"
> >
Publish Publish
</button> </button>
@ -402,7 +382,7 @@ export default function Templates() {
<button <button
type="button" type="button"
onClick={() => setTestTarget(template)} onClick={() => setTestTarget(template)}
className="rounded-full border border-[#c7d6ff] bg-[#f5f8ff] px-3 py-1 text-[13px] font-semibold text-[#4563d5] transition hover:border-[#afc3ff] hover:bg-[#ebf1ff]" className="rounded-full border border-[#c7d6ff] bg-[#f5f8ff] px-4 py-2 text-sm font-semibold text-[#4563d5] transition hover:border-[#afc3ff] hover:bg-[#ebf1ff]"
> >
Test SMS Test SMS
</button> </button>
@ -411,7 +391,7 @@ export default function Templates() {
</div> </div>
{isBoundProfileMissing && ( {isBoundProfileMissing && (
<p className="mt-3 text-[13px] font-medium leading-5 text-gray-500"> <p className="mt-4 text-sm font-medium leading-6 text-gray-500">
{template.curlProfileId {template.curlProfileId
? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.' ? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.'
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.'} : 'This template is not bound to a cURL profile. Re-select it from Events to continue.'}

View File

@ -14,16 +14,6 @@ function firstNonEmptyText(...values) {
return ''; return '';
} }
function getScrapedLogoUrl(entity = {}) {
const branding = entity?.scrapeArtifacts?.json?.branding;
return firstNonEmptyText(
branding?.primaryLogoUrl,
Array.isArray(branding?.logoCandidates) ? branding.logoCandidates[0] : '',
Array.isArray(branding?.logos) ? branding.logos[0] : ''
);
}
function extractDomainName(domain) { function extractDomainName(domain) {
if (!domain) return ''; if (!domain) return '';
if (typeof domain === 'string') return normalizeText(domain); if (typeof domain === 'string') return normalizeText(domain);
@ -180,14 +170,11 @@ export function getBusinessTagline(entity) {
export function getBusinessImage(entity) { export function getBusinessImage(entity) {
const relevantImage = normalizeList(entity?.relevantImagePaths)[0]; const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
const scrapedLogo = getScrapedLogoUrl(entity); if (relevantImage) return relevantImage;
return ( return (
entity?.logoUrl entity?.imageUrl
|| scrapedLogo || entity?.logoUrl
|| entity?.imageUrl
|| entity?.previewImagePath
|| relevantImage
|| entity?.brandImageUrl || entity?.brandImageUrl
|| entity?.image || entity?.image
|| '' || ''

View File

@ -1,7 +1,7 @@
const { Pool } = require('pg'); const { Pool } = require('pg');
const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage'); const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage');
const DEFAULT_TABLE_NAME = 'fdk session storage_SMS'; const DEFAULT_TABLE_NAME = 'fdk session storage';
function normalizeText(value) { function normalizeText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';

File diff suppressed because it is too large Load Diff

View File

@ -1,535 +0,0 @@
const crypto = require('crypto');
const { Pool } = require('pg');
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeInteger(value) {
return Number.isInteger(value) ? value : null;
}
function normalizeTextList(values = []) {
if (!Array.isArray(values)) return [];
return [...new Set(
values
.map((value) => normalizeText(value))
.filter(Boolean)
)];
}
function getConnectionString() {
return normalizeText(
process.env.FDK_STORAGE_CONNECTION_STRING
|| process.env.DATABASE_URL
|| process.env.POSTGRES_CONNECTION_STRING
);
}
let analyticsPool = null;
function getPool() {
if (analyticsPool) return analyticsPool;
const connectionString = getConnectionString();
if (!connectionString) {
throw new Error('Analytics database is not configured');
}
analyticsPool = new Pool({ connectionString });
return analyticsPool;
}
function sha256(value) {
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
}
function buildPhoneMetadata(value) {
const digits = String(value || '').replace(/\D/g, '');
if (!digits) {
return {
toNumberHash: '',
toNumberLast4: '',
};
}
return {
toNumberHash: sha256(digits),
toNumberLast4: digits.slice(-4),
};
}
function buildSourceEventKey({ applicationId, shipmentId, orderId, eventSlug, payload = null }) {
const normalizedParts = {
applicationId: normalizeText(applicationId),
shipmentId: normalizeText(shipmentId),
orderId: normalizeText(orderId),
eventSlug: normalizeText(eventSlug),
};
const hasStableIdentifiers = normalizedParts.applicationId && normalizedParts.eventSlug
&& (normalizedParts.shipmentId || normalizedParts.orderId);
if (hasStableIdentifiers) {
return sha256(JSON.stringify(normalizedParts));
}
return sha256(JSON.stringify({
...normalizedParts,
payload,
}));
}
function buildStatusFingerprint(entry = {}) {
return sha256(JSON.stringify({
messageExecutionId: entry.messageExecutionId,
statusSource: normalizeText(entry.statusSource),
statusType: normalizeText(entry.statusType),
normalizedStatus: normalizeText(entry.normalizedStatus),
providerMessageId: normalizeText(entry.providerMessageId),
providerStatus: normalizeText(entry.providerStatus),
providerStatusCode: normalizeText(entry.providerStatusCode),
errorCode: normalizeText(entry.errorCode),
errorMessage: normalizeText(entry.errorMessage),
payload: entry.payload || null,
}));
}
function extractProviderMessageId(value, depth = 0) {
if (!value || depth > 4) return '';
if (Array.isArray(value)) {
for (const entry of value) {
const nestedMatch = extractProviderMessageId(entry, depth + 1);
if (nestedMatch) return nestedMatch;
}
return '';
}
if (typeof value === 'object') {
const candidates = [
value.message_id,
value.messageId,
value.msg_id,
value.msgid,
value.sms_id,
value.smsId,
value.request_id,
value.requestId,
value.id,
]
.map((entry) => normalizeText(entry))
.filter(Boolean);
if (candidates.length > 0) return candidates[0];
for (const nestedValue of Object.values(value)) {
const nestedMatch = extractProviderMessageId(nestedValue, depth + 1);
if (nestedMatch) return nestedMatch;
}
}
return '';
}
function buildExecutionFilters({ companyId, businessId, eventSlugs }) {
const values = [];
const conditions = [];
if (normalizeText(companyId)) {
values.push(normalizeText(companyId));
conditions.push(`company_id = $${values.length}`);
}
if (normalizeText(businessId)) {
values.push(normalizeText(businessId));
conditions.push(`business_id = $${values.length}`);
}
const normalizedEventSlugs = normalizeTextList(eventSlugs);
if (normalizedEventSlugs.length > 0) {
values.push(normalizedEventSlugs);
conditions.push(`event_slug = ANY($${values.length})`);
}
if (conditions.length === 0) {
throw new Error('Analytics queries require at least one scope filter');
}
return {
whereClause: conditions.join(' AND '),
values,
};
}
function parseCount(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function computeFallbackRate({
deliveredCount = 0,
deliveryFailedCount = 0,
acceptedCount = 0,
sendFailedCount = 0,
}) {
const deliveryTerminalTotal = deliveredCount + deliveryFailedCount;
if (deliveryTerminalTotal > 0) {
return {
rate: deliveredCount / deliveryTerminalTotal,
mode: 'callback',
};
}
const sendTerminalTotal = acceptedCount + sendFailedCount;
if (sendTerminalTotal > 0) {
return {
rate: acceptedCount / sendTerminalTotal,
mode: 'send_fallback',
};
}
return {
rate: null,
mode: 'no_data',
};
}
async function createOrRefreshExecution(entry = {}) {
const pool = getPool();
const result = await pool.query(
`INSERT INTO sms_message_executions (
company_id,
business_id,
application_id,
source_type,
source_event_key,
event_slug,
event_label,
provider_name,
shipment_id,
order_id,
to_number_hash,
to_number_last4,
trigger_payload,
trigger_status,
send_status,
delivery_status,
triggered_at,
is_test
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, COALESCE($17, NOW()), $18
)
ON CONFLICT (company_id, business_id, source_type, source_event_key)
DO UPDATE SET
event_label = COALESCE(EXCLUDED.event_label, sms_message_executions.event_label),
provider_name = COALESCE(EXCLUDED.provider_name, sms_message_executions.provider_name),
shipment_id = COALESCE(EXCLUDED.shipment_id, sms_message_executions.shipment_id),
order_id = COALESCE(EXCLUDED.order_id, sms_message_executions.order_id),
to_number_hash = COALESCE(EXCLUDED.to_number_hash, sms_message_executions.to_number_hash),
to_number_last4 = COALESCE(EXCLUDED.to_number_last4, sms_message_executions.to_number_last4),
trigger_payload = COALESCE(EXCLUDED.trigger_payload, sms_message_executions.trigger_payload),
triggered_at = COALESCE(sms_message_executions.triggered_at, EXCLUDED.triggered_at)
RETURNING *`,
[
normalizeText(entry.companyId),
normalizeText(entry.businessId),
normalizeText(entry.applicationId),
normalizeText(entry.sourceType) || 'fynd_webhook',
normalizeText(entry.sourceEventKey),
normalizeText(entry.eventSlug),
normalizeText(entry.eventLabel),
normalizeText(entry.providerName),
normalizeText(entry.shipmentId),
normalizeText(entry.orderId),
normalizeText(entry.toNumberHash),
normalizeText(entry.toNumberLast4),
entry.triggerPayload || null,
normalizeText(entry.triggerStatus) || 'processed',
normalizeText(entry.sendStatus) || 'not_attempted',
normalizeText(entry.deliveryStatus) || 'unknown',
entry.triggeredAt || null,
entry.isTest === true,
],
);
return result.rows[0] || null;
}
async function markExecutionAccepted(entry = {}) {
const pool = getPool();
const result = await pool.query(
`UPDATE sms_message_executions
SET event_label = COALESCE($2, event_label),
matched_template_event = COALESCE($3, matched_template_event),
template_slug = COALESCE($4, template_slug),
template_id = COALESCE($5, template_id),
curl_profile_id = COALESCE($6, curl_profile_id),
provider_name = COALESCE($7, provider_name),
provider_message_id = COALESCE($8, provider_message_id),
provider_response = COALESCE($9, provider_response),
provider_http_status = COALESCE($10, provider_http_status),
trigger_status = 'processed',
send_status = 'accepted',
delivery_status = CASE
WHEN delivery_status = 'delivered' THEN 'delivered'
WHEN delivery_status = 'failed' THEN 'failed'
ELSE 'pending'
END,
send_attempted_at = COALESCE($11, send_attempted_at, NOW()),
accepted_at = COALESCE($12, accepted_at, NOW())
WHERE id = $1
RETURNING *`,
[
entry.id,
normalizeText(entry.eventLabel),
normalizeText(entry.matchedTemplateEvent),
normalizeText(entry.templateSlug),
normalizeText(entry.templateId),
normalizeText(entry.curlProfileId),
normalizeText(entry.providerName),
normalizeText(entry.providerMessageId),
entry.providerResponse || null,
normalizeInteger(entry.providerHttpStatus),
entry.sendAttemptedAt || null,
entry.acceptedAt || null,
],
);
return result.rows[0] || null;
}
async function markExecutionIgnored(entry = {}) {
const pool = getPool();
const result = await pool.query(
`UPDATE sms_message_executions
SET event_label = COALESCE($2, event_label),
trigger_status = 'ignored',
send_status = CASE
WHEN send_status = 'accepted' THEN send_status
ELSE 'not_attempted'
END,
ignore_reason = COALESCE($3, ignore_reason),
failure_stage = COALESCE($4, failure_stage),
failure_code = COALESCE($5, failure_code),
failure_reason = COALESCE($6, failure_reason)
WHERE id = $1
RETURNING *`,
[
entry.id,
normalizeText(entry.eventLabel),
normalizeText(entry.ignoreReason),
normalizeText(entry.failureStage),
normalizeText(entry.failureCode),
normalizeText(entry.failureReason),
],
);
return result.rows[0] || null;
}
async function markExecutionFailed(entry = {}) {
const pool = getPool();
const result = await pool.query(
`UPDATE sms_message_executions
SET event_label = COALESCE($2, event_label),
matched_template_event = COALESCE($3, matched_template_event),
template_slug = COALESCE($4, template_slug),
template_id = COALESCE($5, template_id),
curl_profile_id = COALESCE($6, curl_profile_id),
provider_name = COALESCE($7, provider_name),
provider_message_id = COALESCE($8, provider_message_id),
provider_response = COALESCE($9, provider_response),
provider_http_status = COALESCE($10, provider_http_status),
trigger_status = 'processed',
send_status = 'send_failed',
delivery_status = 'failed',
failure_stage = COALESCE($11, failure_stage, 'send'),
failure_code = COALESCE($12, failure_code),
failure_reason = COALESCE($13, failure_reason),
send_attempted_at = COALESCE($14, send_attempted_at, NOW()),
failed_at = COALESCE($15, failed_at, NOW())
WHERE id = $1
RETURNING *`,
[
entry.id,
normalizeText(entry.eventLabel),
normalizeText(entry.matchedTemplateEvent),
normalizeText(entry.templateSlug),
normalizeText(entry.templateId),
normalizeText(entry.curlProfileId),
normalizeText(entry.providerName),
normalizeText(entry.providerMessageId),
entry.providerResponse || null,
normalizeInteger(entry.providerHttpStatus),
normalizeText(entry.failureStage),
normalizeText(entry.failureCode),
normalizeText(entry.failureReason),
entry.sendAttemptedAt || null,
entry.failedAt || null,
],
);
return result.rows[0] || null;
}
async function insertStatusHistory(entry = {}) {
const pool = getPool();
const statusFingerprint = normalizeText(entry.statusFingerprint) || buildStatusFingerprint(entry);
const result = await pool.query(
`INSERT INTO sms_message_status_history (
message_execution_id,
status_fingerprint,
status_source,
status_type,
normalized_status,
provider_name,
provider_message_id,
provider_status,
provider_status_code,
error_code,
error_message,
payload,
headers,
occurred_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, COALESCE($14, NOW())
)
ON CONFLICT (status_fingerprint) DO NOTHING
RETURNING *`,
[
entry.messageExecutionId,
statusFingerprint,
normalizeText(entry.statusSource) || 'internal',
normalizeText(entry.statusType),
normalizeText(entry.normalizedStatus),
normalizeText(entry.providerName),
normalizeText(entry.providerMessageId),
normalizeText(entry.providerStatus),
normalizeText(entry.providerStatusCode),
normalizeText(entry.errorCode),
normalizeText(entry.errorMessage),
entry.payload || null,
entry.headers || null,
entry.occurredAt || null,
],
);
return result.rows[0] || null;
}
async function getOverviewMetrics(scope = {}) {
const pool = getPool();
const filters = buildExecutionFilters(scope);
const [summaryResult, chartResult] = await Promise.all([
pool.query(
`SELECT
COUNT(*)::int AS total_triggered,
COUNT(*) FILTER (WHERE COALESCE(triggered_at, created_at) >= CURRENT_DATE)::int AS triggered_today,
COUNT(*) FILTER (
WHERE COALESCE(failed_at, accepted_at, send_attempted_at, triggered_at, created_at) >= NOW() - INTERVAL '24 hours'
AND (send_status = 'send_failed' OR delivery_status = 'failed')
)::int AS failed_last_24_hours,
COUNT(*) FILTER (WHERE send_status = 'accepted')::int AS accepted_count,
COUNT(*) FILTER (WHERE send_status = 'send_failed')::int AS send_failed_count,
COUNT(*) FILTER (WHERE delivery_status = 'delivered')::int AS delivered_count,
COUNT(*) FILTER (WHERE delivery_status = 'failed')::int AS delivery_failed_count
FROM sms_message_executions
WHERE ${filters.whereClause}`,
filters.values,
),
pool.query(
`SELECT
DATE(COALESCE(triggered_at, created_at)) AS day,
COUNT(*)::int AS triggered_count,
COUNT(*) FILTER (WHERE send_status = 'send_failed' OR delivery_status = 'failed')::int AS failed_count
FROM sms_message_executions
WHERE ${filters.whereClause}
AND COALESCE(triggered_at, created_at) >= CURRENT_DATE - INTERVAL '29 days'
GROUP BY 1
ORDER BY 1 ASC`,
filters.values,
),
]);
const summaryRow = summaryResult.rows[0] || {};
const deliveryRate = computeFallbackRate({
deliveredCount: parseCount(summaryRow.delivered_count),
deliveryFailedCount: parseCount(summaryRow.delivery_failed_count),
acceptedCount: parseCount(summaryRow.accepted_count),
sendFailedCount: parseCount(summaryRow.send_failed_count),
});
return {
totalTriggered: parseCount(summaryRow.total_triggered),
triggeredToday: parseCount(summaryRow.triggered_today),
failedLast24Hours: parseCount(summaryRow.failed_last_24_hours),
acceptedCount: parseCount(summaryRow.accepted_count),
sendFailedCount: parseCount(summaryRow.send_failed_count),
deliveredCount: parseCount(summaryRow.delivered_count),
deliveryFailedCount: parseCount(summaryRow.delivery_failed_count),
deliveryRate,
chart: chartResult.rows.map((row) => ({
date: row.day instanceof Date ? row.day.toISOString().slice(0, 10) : String(row.day),
triggeredCount: parseCount(row.triggered_count),
failedCount: parseCount(row.failed_count),
})),
};
}
async function getEventMetrics(scope = {}) {
const pool = getPool();
const filters = buildExecutionFilters(scope);
const result = await pool.query(
`SELECT
event_slug,
MAX(NULLIF(event_label, '')) AS event_label,
COUNT(*)::int AS total_trigger_count,
COUNT(*) FILTER (WHERE COALESCE(triggered_at, created_at) >= CURRENT_DATE)::int AS triggered_today,
MAX(COALESCE(triggered_at, created_at)) AS last_triggered_at,
COUNT(*) FILTER (WHERE send_status = 'accepted')::int AS accepted_count,
COUNT(*) FILTER (WHERE send_status = 'send_failed')::int AS send_failed_count,
COUNT(*) FILTER (WHERE delivery_status = 'delivered')::int AS delivered_count,
COUNT(*) FILTER (WHERE delivery_status = 'failed')::int AS delivery_failed_count
FROM sms_message_executions
WHERE ${filters.whereClause}
GROUP BY event_slug
ORDER BY total_trigger_count DESC, event_slug ASC`,
filters.values,
);
return result.rows.map((row) => ({
eventSlug: normalizeText(row.event_slug),
eventLabel: normalizeText(row.event_label),
totalTriggerCount: parseCount(row.total_trigger_count),
triggeredToday: parseCount(row.triggered_today),
lastTriggeredAt: row.last_triggered_at || null,
acceptedCount: parseCount(row.accepted_count),
sendFailedCount: parseCount(row.send_failed_count),
deliveredCount: parseCount(row.delivered_count),
deliveryFailedCount: parseCount(row.delivery_failed_count),
deliveryRate: computeFallbackRate({
deliveredCount: parseCount(row.delivered_count),
deliveryFailedCount: parseCount(row.delivery_failed_count),
acceptedCount: parseCount(row.accepted_count),
sendFailedCount: parseCount(row.send_failed_count),
}),
}));
}
module.exports = {
buildPhoneMetadata,
buildSourceEventKey,
computeFallbackRate,
createOrRefreshExecution,
extractProviderMessageId,
getOverviewMetrics,
getEventMetrics,
insertStatusHistory,
markExecutionAccepted,
markExecutionFailed,
markExecutionIgnored,
};

View File

@ -6,38 +6,6 @@ function normalizeList(value) {
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
function isAbsoluteHttpUrl(value) {
const normalized = normalizeText(value);
if (!normalized) return false;
try {
const parsed = new URL(normalized);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function collectImageUrls(value, bucket = []) {
if (Array.isArray(value)) {
value.forEach((entry) => collectImageUrls(entry, bucket));
return bucket;
}
if (typeof value === 'string') {
if (isAbsoluteHttpUrl(value)) bucket.push(value);
return bucket;
}
if (!value || typeof value !== 'object') {
return bucket;
}
const url = normalizeText(value.url || value.href || value.src || value.secure_url);
if (isAbsoluteHttpUrl(url)) bucket.push(url);
return bucket;
}
function uniqueStrings(values) { function uniqueStrings(values) {
const seen = new Set(); const seen = new Set();
return normalizeList(values) return normalizeList(values)
@ -108,46 +76,6 @@ function dedupeLinks(links) {
}); });
} }
function scoreLogoCandidate(url) {
const normalized = normalizeText(url).toLowerCase();
if (!normalized) return -100;
let score = 0;
if (/logo|brandmark|wordmark|logomark/.test(normalized)) score += 40;
if (/favicon|apple-touch-icon|android-chrome|mstile|site-icon|siteicon|icon/.test(normalized)) score += 25;
if (/header|navbar|nav/.test(normalized)) score += 8;
if (/hero|banner|carousel|slider|product|collection|catalog|lookbook/.test(normalized)) score -= 35;
if (/social|facebook|instagram|twitter|linkedin|youtube|pinterest|avatar|profile/.test(normalized)) score -= 25;
if (/sprite|tracking|pixel|placeholder/.test(normalized)) score -= 40;
return score;
}
function rankLogoCandidates(values) {
return uniqueStrings(values)
.map((url, index) => ({
index,
score: scoreLogoCandidate(url),
url,
}))
.sort((left, right) => right.score - left.score || left.index - right.index)
.map((entry) => entry.url);
}
function extractMetadataImageCandidates(metadata = {}) {
const candidates = [];
Object.entries(metadata || {}).forEach(([key, value]) => {
const normalizedKey = normalizeText(key).toLowerCase();
if (!/(image|logo|icon|favicon|thumbnail|apple)/.test(normalizedKey)) return;
collectImageUrls(value, candidates);
});
return rankLogoCandidates(candidates).filter((url) => scoreLogoCandidate(url) > 0);
}
function summarizePage(page, pageType) { function summarizePage(page, pageType) {
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {}; const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
@ -173,7 +101,7 @@ function buildRepresentativeTextBlocks(homepage, aboutPage, productPages) {
})); }));
} }
function flattenBranding(homepage, topImages = []) { function flattenBranding(homepage) {
const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {}; const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {};
const colorEntries = []; const colorEntries = [];
const logos = []; const logos = [];
@ -214,22 +142,12 @@ function flattenBranding(homepage, topImages = []) {
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name); const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
if (brandName) brandNames.push(brandName); if (brandName) brandNames.push(brandName);
const metadataImageCandidates = extractMetadataImageCandidates(homepage?.metadata || {});
const topLogoCandidates = rankLogoCandidates(topImages).filter((url) => scoreLogoCandidate(url) > 0);
const logoCandidates = uniqueStrings([
...logos,
...metadataImageCandidates,
...topLogoCandidates,
]);
return { return {
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)), colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
labeledColors: colorEntries.filter((entry, index, values) => ( labeledColors: colorEntries.filter((entry, index, values) => (
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
)), )),
logos: uniqueStrings(logos), logos: uniqueStrings(logos),
logoCandidates,
primaryLogoUrl: logoCandidates[0] || '',
brandNames: uniqueStrings(brandNames), brandNames: uniqueStrings(brandNames),
}; };
} }
@ -270,7 +188,7 @@ function buildCrawlSummary(data = {}, startUrlOverride = '') {
...normalizeList(aboutRaw?.images), ...normalizeList(aboutRaw?.images),
...productRawPages.flatMap((page) => normalizeList(page?.images)), ...productRawPages.flatMap((page) => normalizeList(page?.images)),
]).slice(0, 60); ]).slice(0, 60);
const branding = flattenBranding(homepageRaw, topImages); const branding = flattenBranding(homepageRaw);
return { return {
startUrl, startUrl,

View File

@ -1,821 +0,0 @@
const { spawn } = require('child_process');
const STATUS_MARKER = '__CODEX_HTTP_STATUS__:';
const DEFAULT_TIMEOUT_MS = 30000;
const MAX_CAPTURE_LENGTH = 1024 * 1024;
const DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']);
const HEADER_FLAGS = new Set(['--header', '-H']);
const METHOD_FLAGS = new Set(['--request', '-X']);
const IGNORED_HEADER_KEYS = new Set(['content-length']);
const HEADER_ID_PREFIX = 'header';
const STRIP_VALUE_FLAGS = new Set(['--write-out', '-w', '--output', '-o', '--dump-header', '-D']);
const STRIP_BOOLEAN_FLAGS = new Set([
'--silent',
'-s',
'--show-error',
'-S',
'--include',
'-i',
'--verbose',
'-v',
'--remote-name',
'-O',
'--remote-header-name',
'-J',
'--fail',
'-f',
'--fail-with-body',
]);
const TOKEN_REGEX = /__(?:PROFILE|SMS)_[A-Z0-9_]+__/g;
function createExecutionError(message, extra = {}) {
const error = new Error(message);
Object.assign(error, extra);
return error;
}
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function skipShellIndentation(input, index) {
let cursor = index;
while (cursor < input.length && /[\t \f\v\u00a0]/.test(input[cursor])) {
cursor += 1;
}
return cursor;
}
function normalizeCommand(command) {
const input = String(command || '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
let output = '';
let quote = null;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (quote === '\'') {
output += char;
if (char === '\'') {
quote = null;
}
continue;
}
if (quote === '"') {
output += char;
if (char === '\\' && index + 1 < input.length) {
output += input[index + 1];
index += 1;
continue;
}
if (char === '"') {
quote = null;
}
continue;
}
if (char === '\'' || char === '"') {
quote = char;
output += char;
continue;
}
if (char === '\\') {
const nextChar = input[index + 1];
if (nextChar === '\n') {
output += ' ';
index = skipShellIndentation(input, index + 2) - 1;
continue;
}
if (nextChar === 'n') {
output += ' ';
index = skipShellIndentation(input, index + 2) - 1;
continue;
}
if (nextChar === 'r' && input[index + 2] === 'n') {
output += ' ';
index = skipShellIndentation(input, index + 3) - 1;
continue;
}
}
output += char;
}
return output.trim();
}
function tokenizeCurlCommand(command) {
const input = normalizeCommand(command);
const tokens = [];
let current = '';
let quote = null;
let escaping = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (escaping) {
current += char;
escaping = false;
continue;
}
if (quote === '\'') {
if (char === '\'') {
quote = null;
} else {
current += char;
}
continue;
}
if (quote === '"') {
if (char === '"') {
quote = null;
continue;
}
if (char === '\\') {
const nextChar = input[index + 1];
if (nextChar) {
current += nextChar;
index += 1;
continue;
}
}
current += char;
continue;
}
if (char === '\\') {
escaping = true;
continue;
}
if (char === '\'' || char === '"') {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (escaping) {
current += '\\';
}
if (quote) {
throw createExecutionError('Stored cURL contains an unterminated quoted value.', {
code: 'INVALID_CURL_TEMPLATE',
});
}
if (current) {
tokens.push(current);
}
return tokens;
}
function parseCurlCommand(command) {
const tokens = tokenizeCurlCommand(command);
if (tokens.length === 0 || tokens[0] !== 'curl') {
throw createExecutionError('Stored cURL template must start with "curl".', {
code: 'INVALID_CURL_TEMPLATE',
});
}
return {
command: 'curl',
args: tokens.slice(1),
};
}
function collectHeaders(args = []) {
const headers = {};
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
let rawHeader = '';
if (HEADER_FLAGS.has(argument) && index + 1 < args.length) {
rawHeader = String(args[index + 1] || '');
index += 1;
} else if (argument.startsWith('--header=')) {
rawHeader = argument.slice('--header='.length);
} else if (argument.startsWith('-H=')) {
rawHeader = argument.slice(3);
} else {
continue;
}
const separatorIndex = rawHeader.indexOf(':');
if (separatorIndex < 0) continue;
const key = normalizeText(rawHeader.slice(0, separatorIndex));
const value = normalizeText(rawHeader.slice(separatorIndex + 1));
if (!key) continue;
headers[key] = value;
}
return headers;
}
function sanitizeHeaders(headers = {}) {
return Object.fromEntries(
Object.entries(headers || {}).filter(([key]) => {
const normalizedKey = normalizeText(key).toLowerCase();
return normalizedKey && !IGNORED_HEADER_KEYS.has(normalizedKey);
}),
);
}
function getDataArguments(args = []) {
const dataArgs = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (DATA_FLAGS.has(argument) && index + 1 < args.length) {
dataArgs.push(String(args[index + 1] || ''));
index += 1;
continue;
}
const flag = Array.from(DATA_FLAGS).find((entry) => argument.startsWith(`${entry}=`));
if (flag) {
dataArgs.push(argument.slice(flag.length + 1));
}
}
return dataArgs;
}
function extractMethod(args = [], dataArgs = []) {
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (METHOD_FLAGS.has(argument) && index + 1 < args.length) {
return normalizeText(args[index + 1]).toUpperCase() || 'POST';
}
if (argument.startsWith('--request=')) {
return normalizeText(argument.slice('--request='.length)).toUpperCase() || 'POST';
}
if (argument.startsWith('-X=')) {
return normalizeText(argument.slice(3)).toUpperCase() || 'POST';
}
}
return dataArgs.length > 0 ? 'POST' : 'GET';
}
function extractUrl(args = []) {
for (let index = 0; index < args.length; index += 1) {
const argument = String(args[index] || '');
if (argument === '--url' && index + 1 < args.length) {
return normalizeText(args[index + 1]);
}
if (argument.startsWith('--url=')) {
return normalizeText(argument.slice('--url='.length));
}
if (/^https?:\/\//i.test(argument)) {
return normalizeText(argument);
}
}
return '';
}
function buildHeaderEntries(headers = {}) {
return Object.entries(sanitizeHeaders(headers)).map(([key, value], index) => ({
id: `${HEADER_ID_PREFIX}-${index}`,
key,
value,
enabled: true,
}));
}
function normalizeHeaderEntries(headerEntries = [], fallbackHeaders = {}) {
const normalizedEntries = [];
const seenIds = new Set();
(Array.isArray(headerEntries) ? headerEntries : []).forEach((entry, index) => {
const key = normalizeText(entry?.key);
const value = normalizeText(entry?.value);
const enabled = entry?.enabled !== false;
const fallbackId = `${HEADER_ID_PREFIX}-${index}`;
const rawId = normalizeText(entry?.id) || fallbackId;
const id = seenIds.has(rawId) ? `${rawId}-${index}` : rawId;
if (!key && !value) return;
seenIds.add(id);
normalizedEntries.push({
id,
key,
value,
enabled,
});
});
if (normalizedEntries.length > 0) {
return normalizedEntries;
}
if (Array.isArray(fallbackHeaders)) {
return normalizeHeaderEntries(fallbackHeaders, {});
}
return buildHeaderEntries(fallbackHeaders);
}
function buildRequestBlueprintFromCurl(rawCurlTemplate = '') {
const parsed = parseCurlCommand(String(rawCurlTemplate || ''));
const url = extractUrl(parsed.args);
if (!url) {
throw createExecutionError('Stored cURL template must include an absolute http(s) URL.', {
code: 'INVALID_CURL_TEMPLATE',
status: 422,
});
}
const dataArgs = getDataArguments(parsed.args);
const method = extractMethod(parsed.args, dataArgs);
const headers = sanitizeHeaders(collectHeaders(parsed.args));
return {
method,
url,
headers,
headerEntries: buildHeaderEntries(headers),
};
}
function quoteCurlValue(value = '') {
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
}
function serializeCurlTemplateFromArgs(args = []) {
return ['curl', ...(Array.isArray(args) ? args : []).map((argument) => quoteCurlValue(argument))].join(' ');
}
function buildPatchedCurlTemplateFromRequest(rawCurlTemplate = '', requestPatch = {}) {
const parsed = parseCurlCommand(String(rawCurlTemplate || ''));
const currentRequest = buildRequestBlueprintFromCurl(rawCurlTemplate);
const nextUrl = normalizeText(requestPatch?.url) || currentRequest.url;
const nextHeaders = normalizeHeaderEntries(
requestPatch?.headers,
currentRequest.headerEntries,
);
const nextHeaderArgs = nextHeaders
.filter((entry) => entry.enabled !== false)
.filter((entry) => normalizeText(entry.key))
.flatMap((entry) => ['--header', `${entry.key}: ${entry.value}`]);
const patchedArgs = [];
let insertedUrl = false;
let insertedHeaders = false;
for (let index = 0; index < parsed.args.length; index += 1) {
const argument = parsed.args[index];
if (HEADER_FLAGS.has(argument) && index + 1 < parsed.args.length) {
if (!insertedHeaders) {
patchedArgs.push(...nextHeaderArgs);
insertedHeaders = true;
}
index += 1;
continue;
}
if (argument.startsWith('--header=') || argument.startsWith('-H=')) {
if (!insertedHeaders) {
patchedArgs.push(...nextHeaderArgs);
insertedHeaders = true;
}
continue;
}
if (argument === '--url' && index + 1 < parsed.args.length) {
if (!insertedUrl) {
patchedArgs.push('--url', nextUrl);
insertedUrl = true;
}
index += 1;
continue;
}
if (argument.startsWith('--url=')) {
if (!insertedUrl) {
patchedArgs.push('--url', nextUrl);
insertedUrl = true;
}
continue;
}
if (/^https?:\/\//i.test(String(argument || ''))) {
if (!insertedUrl) {
patchedArgs.push(nextUrl);
insertedUrl = true;
}
continue;
}
patchedArgs.push(argument);
}
if (!insertedUrl) {
patchedArgs.push('--url', nextUrl);
}
if (!insertedHeaders && nextHeaderArgs.length > 0) {
patchedArgs.push(...nextHeaderArgs);
}
return serializeCurlTemplateFromArgs(patchedArgs);
}
function replaceTokensInString(value, tokenValues = {}) {
let output = String(value || '');
const entries = Object.entries(tokenValues).sort((left, right) => right[0].length - left[0].length);
entries.forEach(([token, replacement]) => {
if (!token) return;
output = output.split(token).join(String(replacement ?? ''));
});
return output;
}
function replaceTokensInJsonValue(value, tokenValues = {}) {
if (Array.isArray(value)) {
return value.map((entry) => replaceTokensInJsonValue(entry, tokenValues));
}
if (value && typeof value === 'object') {
return Object.entries(value).reduce((accumulator, [key, entry]) => {
accumulator[key] = replaceTokensInJsonValue(entry, tokenValues);
return accumulator;
}, {});
}
if (typeof value === 'string') {
return replaceTokensInString(value, tokenValues);
}
return value;
}
function normalizeJsonFormattingEscapes(value) {
const input = String(value || '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
let output = '';
let inString = false;
let escaping = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (inString) {
output += char;
if (escaping) {
escaping = false;
continue;
}
if (char === '\\') {
escaping = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === '\\') {
const nextChar = input[index + 1];
if (nextChar === 'n') {
output += '\n';
index += 1;
continue;
}
if (nextChar === 'r' && input[index + 2] === 'n') {
output += '\n';
index += 2;
continue;
}
if (nextChar === 'r') {
output += '\n';
index += 1;
continue;
}
if (nextChar === 't') {
output += '\t';
index += 1;
continue;
}
}
output += char;
}
return output;
}
function parseJsonLikeArgument(value) {
const trimmed = String(value || '').trim();
if (
!trimmed
|| !(
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
)
) {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
const normalized = normalizeJsonFormattingEscapes(trimmed);
if (normalized === trimmed) {
return null;
}
try {
return JSON.parse(normalized);
} catch {
return null;
}
}
}
function hydrateDataArgument(rawArgument, tokenValues = {}) {
const trimmed = String(rawArgument || '').trim();
if (!trimmed) return '';
const parsedJson = parseJsonLikeArgument(trimmed);
if (parsedJson !== null) {
return JSON.stringify(replaceTokensInJsonValue(parsedJson, tokenValues));
}
return replaceTokensInString(rawArgument, tokenValues);
}
function hydrateCurlArgs(args = [], tokenValues = {}) {
const hydratedArgs = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (DATA_FLAGS.has(argument) && index + 1 < args.length) {
hydratedArgs.push(argument);
hydratedArgs.push(hydrateDataArgument(args[index + 1], tokenValues));
index += 1;
continue;
}
const dataFlagWithValue = Array.from(DATA_FLAGS).find((flag) => argument.startsWith(`${flag}=`));
if (dataFlagWithValue) {
const rawValue = argument.slice(dataFlagWithValue.length + 1);
hydratedArgs.push(`${dataFlagWithValue}=${hydrateDataArgument(rawValue, tokenValues)}`);
continue;
}
hydratedArgs.push(replaceTokensInString(argument, tokenValues));
}
return hydratedArgs;
}
function normalizeExecutionArgs(args = []) {
const normalizedArgs = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (STRIP_BOOLEAN_FLAGS.has(argument)) {
continue;
}
const stripValueFlag = Array.from(STRIP_VALUE_FLAGS).find((flag) => argument === flag || argument.startsWith(`${flag}=`));
if (stripValueFlag) {
if (argument === stripValueFlag) {
index += 1;
}
continue;
}
normalizedArgs.push(argument);
}
normalizedArgs.push(
'--silent',
'--show-error',
'--output',
'-',
'--write-out',
`\n${STATUS_MARKER}%{http_code}`,
);
return normalizedArgs;
}
function findUnresolvedTokens(args = []) {
const unresolved = new Set();
args.forEach((argument) => {
const matches = String(argument || '').match(TOKEN_REGEX) || [];
matches.forEach((token) => unresolved.add(token));
});
return Array.from(unresolved);
}
function appendChunk(buffer, chunk) {
const nextValue = `${buffer}${chunk}`;
if (nextValue.length <= MAX_CAPTURE_LENGTH) return nextValue;
return nextValue.slice(nextValue.length - MAX_CAPTURE_LENGTH);
}
function parseCurlStdout(stdout = '') {
const marker = `\n${STATUS_MARKER}`;
const markerIndex = stdout.lastIndexOf(marker);
if (markerIndex < 0) {
return {
statusCode: 0,
body: stdout,
};
}
const statusText = stdout.slice(markerIndex + marker.length).trim();
const parsedStatusCode = Number.parseInt(statusText, 10);
return {
statusCode: Number.isFinite(parsedStatusCode) ? parsedStatusCode : 0,
body: stdout.slice(0, markerIndex),
};
}
function parseResponseBody(body = '') {
const normalizedBody = String(body || '').trim();
if (!normalizedBody) return '';
try {
return JSON.parse(normalizedBody);
} catch {
return body;
}
}
function executeParsedCurl(command, args = [], options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const child = spawn(command, normalizeExecutionArgs(args), {
shell: false,
env: process.env,
});
let stdout = '';
let stderr = '';
let timedOut = false;
let settled = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
setTimeout(() => {
if (!settled) child.kill('SIGKILL');
}, 1500).unref();
}, timeoutMs);
child.stdout.on('data', (chunk) => {
stdout = appendChunk(stdout, chunk.toString());
});
child.stderr.on('data', (chunk) => {
stderr = appendChunk(stderr, chunk.toString());
});
child.on('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(createExecutionError(`Failed to start curl: ${error.message}`, {
code: 'CURL_EXECUTION_START_FAILED',
}));
});
child.on('close', (exitCode, signal) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
const parsedStdout = parseCurlStdout(stdout);
const response = parseResponseBody(parsedStdout.body);
if (timedOut) {
reject(createExecutionError(`curl execution timed out after ${timeoutMs}ms`, {
code: 'CURL_EXECUTION_TIMEOUT',
details: {
timeoutMs,
stderr: stderr.trim(),
statusCode: parsedStdout.statusCode,
},
}));
return;
}
if (exitCode !== 0) {
reject(createExecutionError('curl execution failed', {
code: 'CURL_EXECUTION_FAILED',
details: {
exitCode,
signal,
stderr: stderr.trim(),
statusCode: parsedStdout.statusCode,
response,
},
}));
return;
}
resolve({
success: parsedStdout.statusCode >= 200 && parsedStdout.statusCode < 300,
exitCode,
signal,
statusCode: parsedStdout.statusCode,
response,
stderr: stderr.trim(),
});
});
});
}
async function executeTemplatedCurl(curlTemplate, tokenValues = {}, options = {}) {
const parsed = parseCurlCommand(curlTemplate);
const hydratedArgs = hydrateCurlArgs(parsed.args, tokenValues);
const unresolvedTokens = findUnresolvedTokens(hydratedArgs);
if (unresolvedTokens.length > 0) {
throw createExecutionError('Stored cURL still contains unresolved execution tokens.', {
code: 'UNRESOLVED_CURL_TOKENS',
details: {
unresolvedTokens,
},
});
}
return executeParsedCurl(parsed.command, hydratedArgs, options);
}
module.exports = {
buildPatchedCurlTemplateFromRequest,
buildRequestBlueprintFromCurl,
executeTemplatedCurl,
normalizeHeaderEntries,
parseCurlCommand,
};

File diff suppressed because it is too large Load Diff