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';
@ -165,7 +165,7 @@ function StatusBadge({ status }) {
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);
} else {
setInitialLoading(true);
setError(''); 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>
{refreshing && ( <p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
<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,27 +1336,50 @@ 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;
return (
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
<button
type="button"
onClick={() => toggleGroup(group.id)}
className="group flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50"
>
<div className="flex min-w-0 items-start gap-4">
<div className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${groupStyle.markerShell}`}>
<span className={`h-2.5 w-2.5 rounded-full ${groupStyle.markerDot}`} />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-800">{section.label}</h2> <h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500"> <span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
{section.events.length} events {group.events.length} events
</span> </span>
</div> </div>
<p className="mt-1 text-sm font-medium text-gray-500">{section.description}</p> <p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
</div> </div>
</div>
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-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"> <div className="space-y-4">
{section.events.map((event) => { {group.events.map((event) => {
const state = genState[event.slug] || 'idle'; const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || []; const eventVariants = variants[event.slug] || [];
const templateStatus = templateStatusBySlug[event.slug] || 'unselected'; const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
@ -1399,8 +1416,9 @@ export default function Events() {
<span <span
title={statusConfig.label} title={statusConfig.label}
aria-label={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}`} 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} {statusConfig.label}
</span> </span>
<button <button
@ -1440,8 +1458,11 @@ export default function Events() {
); );
})} })}
</div> </div>
</div>
)}
</section> </section>
))} );
})}
</div> </div>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -1,130 +1,31 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']); const DESKTOP_SPLIT_QUERY = '(min-width: 1100px)';
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID'; const DEFAULT_LIST_PANE_WIDTH = 340;
const MIN_LIST_PANE_WIDTH = 280;
const MAX_LIST_PANE_WIDTH = 420;
const MIN_DETAIL_PANE_WIDTH = 440;
function isPendingSenderIdProfile(profile) { function clamp(value, min, max) {
const normalizedName = String(profile?.name || '').trim(); return Math.min(Math.max(value, min), max);
const senderId = String(profile?.provider?.senderId || '').trim();
return (profile?.isAutoNamed === true && !senderId) || normalizedName === PENDING_SENDER_ID_PROFILE_NAME;
} }
function normalizeCurlForDisplay(value) { function getMissingProviderFields(profile) {
if (!value) return ''; const provider = profile?.provider || {};
const missing = [];
return String(value) if (!provider.providerName) missing.push('Provider Name');
.trim() if (!provider.senderId) missing.push('Sender ID');
.replace(/\r\n/g, '\n') if (!provider.dltEntityId) missing.push('DLT Entity ID');
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n') return missing;
.replace(/\\t/g, ' ')
.replace(/\\'/g, '\'')
.replace(/\\"/g, '"');
} }
function stripWrappingQuotes(value) { function isProviderSetupComplete(profile) {
if (!value || value.length < 2) return value; return getMissingProviderFields(profile).length === 0;
if (
(value.startsWith('\'') && value.endsWith('\''))
|| (value.startsWith('"') && value.endsWith('"'))
) {
return value.slice(1, -1);
}
return value;
}
function formatCurlCommand(normalizedCurl) {
if (!normalizedCurl) return '';
let output = normalizedCurl;
if (!output.includes('\n')) {
output = output
.replace(/^curl\s+/, 'curl\n ')
.replace(/\s+(--request|-X|--url|--header|-H|--data-raw|--data|-d|--compressed|--location|--insecure|--fail)\b/g, '\n $1');
}
return output.replace(/\n{3,}/g, '\n\n').trim();
}
function extractCurlBody(normalizedCurl) {
if (!normalizedCurl) return '';
const quotedMatch = normalizedCurl.match(
/(?:--data-raw|--data|-d)\s+(["'])([\s\S]*?)\1(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
);
if (quotedMatch?.[2]) return stripWrappingQuotes(quotedMatch[2].trim());
const braceMatch = normalizedCurl.match(
/(?:--data-raw|--data|-d)\s+({[\s\S]*})(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
);
return braceMatch?.[1]?.trim() || '';
}
function buildCurlViewModel(value) {
const normalizedCurl = normalizeCurlForDisplay(value);
const headers = [
...normalizedCurl.matchAll(/(?:--header|-H)\s+(?:"([^"]+)"|'([^']+)')/g),
]
.map((match) => (match[1] || match[2] || '').trim())
.filter(Boolean);
const methodMatch = normalizedCurl.match(/(?:--request|-X)\s+([A-Z]+)/i);
const method = (methodMatch?.[1] || (/(?:--data-raw|--data|-d)\b/i.test(normalizedCurl) ? 'POST' : 'GET')).toUpperCase();
const url = normalizedCurl.match(/https?:\/\/[^\s'"]+/i)?.[0] || '';
const rawBody = extractCurlBody(normalizedCurl);
let payload = stripWrappingQuotes(rawBody || '').trim();
let prettyPayload = '';
let payloadFormat = '';
if (payload) {
try {
const parsed = JSON.parse(payload);
prettyPayload = JSON.stringify(parsed, null, 2);
payloadFormat = 'json';
} catch {
prettyPayload = payload;
payloadFormat = 'text';
}
}
let host = '';
try {
host = url ? new URL(url).host : '';
} catch {
host = '';
}
const shellLines = [];
if (url) {
shellLines.push('curl \\');
shellLines.push(` --request ${method} \\`);
shellLines.push(` --url '${url}'${headers.length || rawBody ? ' \\' : ''}`);
headers.forEach((header, index) => {
const hasTrailingSection = index < headers.length - 1 || Boolean(rawBody);
shellLines.push(` --header '${header}'${hasTrailingSection ? ' \\' : ''}`);
});
if (rawBody) {
shellLines.push(` --data-raw '${payloadFormat === 'json' ? '<payload shown below>' : rawBody}'`);
}
}
return {
command: shellLines.length > 0 ? shellLines.join('\n') : formatCurlCommand(normalizedCurl),
headers,
host,
method,
payload: prettyPayload,
payloadFormat,
url,
};
} }
function formatUpdatedAt(value) { function formatUpdatedAt(value) {
@ -137,97 +38,58 @@ function formatUpdatedAt(value) {
} }
} }
function buildProfilePatchPayload(inputs = [], values = {}) { function buildProviderSummary(profile) {
const provider = {};
const profileInputValues = {};
inputs.forEach((input) => {
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 getInitialFormValues(inputs = []) {
return inputs.reduce((accumulator, input) => {
accumulator[input.key] = input.secret ? '' : (input.value || '');
return accumulator;
}, {});
}
function getProfileSummary(profile) {
const parts = [];
const provider = profile?.provider || {}; const provider = profile?.provider || {};
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0; const parts = [];
if (provider.providerName) parts.push(provider.providerName); if (provider.providerName) parts.push(provider.providerName);
if (provider.senderId) parts.push(`Sender ${provider.senderId}`); if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
if (provider.dltEntityId) parts.push('DLT ready'); if (provider.dltEntityId) parts.push('DLT added');
if (missingCount > 0) parts.push(`${missingCount} pending`);
return parts.join(' • ') || 'Profile saved. Complete the required fields to use it everywhere.'; return parts.length > 0 ? parts.join(' • ') : 'Provider details not completed yet';
} }
function ProfileStatusPill({ complete }) { function ProfileStatusPill({ complete }) {
return ( return (
<span <span
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${ className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${complete
complete
? 'border-emerald-200 bg-emerald-50 text-emerald-700' ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-700' : 'border-amber-200 bg-amber-50 text-amber-700'
}`} }`}
> >
{complete ? 'Ready' : 'Needs Fields'} {complete ? 'Complete' : 'Missing Fields'}
</span> </span>
); );
} }
function InspectorRow({ label, value, valueClassName = '' }) {
return (
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">{label}</p>
<p className={`mt-2 text-sm font-medium text-gray-900 ${valueClassName}`}>{value}</p>
</div>
);
}
export default function Providers() { export default function Providers() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { refreshOnboardingState } = useBusiness(); const { refreshOnboardingState } = useBusiness();
const hasLoadedProfilesRef = useRef(false); const [loading, setLoading] = useState(true);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [profiles, setProfiles] = useState([]); const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(''); const [activeProfileId, setActiveProfileId] = useState('');
const [selectedProfileId, setSelectedProfileId] = useState(''); const [selectedProfileId, setSelectedProfileId] = useState('');
const [formValues, setFormValues] = useState({}); const [form, setForm] = useState({
const [revealedProfiles, setRevealedProfiles] = useState({}); providerName: '',
const [showSecretsByProfileId, setShowSecretsByProfileId] = useState({}); senderId: '',
dltEntityId: '',
authKey: '',
});
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [copiedProfileId, setCopiedProfileId] = useState('');
const [isDesktopSplit, setIsDesktopSplit] = useState(false);
const [listPaneWidth, setListPaneWidth] = useState(DEFAULT_LIST_PANE_WIDTH);
const layoutRef = useRef(null);
const copyTimeoutRef = useRef(null);
const globalSmsPath = `/${businessId}/global-sms`; const globalSmsPath = `/${businessId}/global-sms`;
const loadProfiles = useCallback(async ({ background = false } = {}) => { const loadProfiles = useCallback(async () => {
try { try {
if (background) { setLoading(true);
setRefreshing(true);
} else {
setInitialLoading(true);
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`); const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || []; const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = String(res.data?.activeProfileId || ''); const nextActiveProfileId = String(res.data?.activeProfileId || '');
@ -239,58 +101,63 @@ export default function Providers() {
? currentSelectedProfileId ? currentSelectedProfileId
: '' : ''
)); ));
setError('');
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to load provider profiles'); setError(err.response?.data?.error || 'Failed to load provider profiles');
} finally { } finally {
if (background) { setLoading(false);
setRefreshing(false);
} else {
hasLoadedProfilesRef.current = true;
setInitialLoading(false);
}
} }
}, [businessId]); }, [businessId]);
useEffect(() => { useEffect(() => {
loadProfiles({ background: hasLoadedProfilesRef.current }); loadProfiles();
}, [loadProfiles]); }, [loadProfiles]);
const selectedProfile = useMemo( useEffect(() => {
() => profiles.find((profile) => profile.id === selectedProfileId) || null, const mediaQuery = window.matchMedia(DESKTOP_SPLIT_QUERY);
[profiles, selectedProfileId], const syncLayoutMode = (event) => setIsDesktopSplit(event.matches);
);
const selectedProfileInputs = selectedProfile?.profileInputs || []; setIsDesktopSplit(mediaQuery.matches);
const isSelectedProfileRevealed = selectedProfile ? showSecretsByProfileId[selectedProfile.id] === true : false;
const selectedRevealData = selectedProfile ? revealedProfiles[selectedProfile.id] : null; if (typeof mediaQuery.addEventListener === 'function') {
const selectedDisplayCurl = selectedProfile mediaQuery.addEventListener('change', syncLayoutMode);
? (isSelectedProfileRevealed return () => mediaQuery.removeEventListener('change', syncLayoutMode);
? (selectedRevealData?.rawCurl || selectedProfile.maskedCurl) }
: selectedProfile.maskedCurl)
: ''; mediaQuery.addListener(syncLayoutMode);
const selectedCurlView = useMemo( return () => mediaQuery.removeListener(syncLayoutMode);
() => buildCurlViewModel(selectedDisplayCurl), }, []);
[selectedDisplayCurl],
); useEffect(() => () => {
const missingInputCount = selectedProfile?.executionReadiness?.missingProfileInputs?.length || 0; if (copyTimeoutRef.current) {
const curlWarnings = selectedProfile?.curlAnalysis?.warnings || []; clearTimeout(copyTimeoutRef.current);
}
}, []);
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) || null;
useEffect(() => { useEffect(() => {
if (!selectedProfile) { if (!selectedProfile) {
setFormValues({}); setForm({
providerName: '',
senderId: '',
dltEntityId: '',
authKey: '',
});
return; return;
} }
setFormValues(getInitialFormValues(selectedProfile.profileInputs)); const provider = selectedProfile.provider || {};
setForm({
providerName: provider.providerName || '',
senderId: provider.senderId || '',
dltEntityId: provider.dltEntityId || '',
authKey: provider.authKey || '',
});
}, [selectedProfile]); }, [selectedProfile]);
const ensureRevealData = useCallback(async (profileId) => { function handleChange(field, value) {
if (revealedProfiles[profileId]) return revealedProfiles[profileId]; setForm((prev) => ({ ...prev, [field]: value }));
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
return res.data;
}, [businessId, revealedProfiles]);
function handleSelectProfile(profileId) { function handleSelectProfile(profileId) {
setSelectedProfileId(profileId); setSelectedProfileId(profileId);
@ -298,10 +165,37 @@ export default function Providers() {
setSuccess(''); setSuccess('');
} }
function handleReturnToList() { function handleResizeStart(event) {
setSelectedProfileId(''); if (!isDesktopSplit) return;
setError('');
setSuccess(''); event.preventDefault();
const containerBounds = layoutRef.current?.getBoundingClientRect();
if (!containerBounds) return;
const maxAllowedWidth = clamp(
containerBounds.width - MIN_DETAIL_PANE_WIDTH,
MIN_LIST_PANE_WIDTH,
MAX_LIST_PANE_WIDTH,
);
function handlePointerMove(moveEvent) {
const nextWidth = clamp(
moveEvent.clientX - containerBounds.left,
MIN_LIST_PANE_WIDTH,
maxAllowedWidth,
);
setListPaneWidth(nextWidth);
}
function handlePointerUp() {
document.body.style.userSelect = '';
window.removeEventListener('mousemove', handlePointerMove);
window.removeEventListener('mouseup', handlePointerUp);
}
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', handlePointerMove);
window.addEventListener('mouseup', handlePointerUp);
} }
async function handleActivate(profile) { async function handleActivate(profile) {
@ -312,7 +206,7 @@ export default function Providers() {
setSuccess(''); setSuccess('');
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`); await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
setSelectedProfileId(profile.id); setSelectedProfileId(profile.id);
await loadProfiles({ background: true }); await loadProfiles();
await refreshOnboardingState(businessId).catch(() => null); await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`${profile.name} is now the active profile.`); setSuccess(`${profile.name} is now the active profile.`);
} catch (err) { } catch (err) {
@ -320,57 +214,55 @@ export default function Providers() {
} }
} }
async function handleToggleReveal(profile) {
if (!profile?.id) return;
const shouldReveal = !showSecretsByProfileId[profile.id];
if (shouldReveal) {
try {
const revealData = await ensureRevealData(profile.id);
const revealedValues = (revealData?.profileInputs || []).reduce((accumulator, input) => {
accumulator[input.key] = input.value || '';
return accumulator;
}, {});
setFormValues((current) => ({ ...current, ...revealedValues }));
} catch (err) {
setError(err.response?.data?.error || 'Failed to reveal saved values');
return;
}
}
setShowSecretsByProfileId((current) => ({
...current,
[profile.id]: shouldReveal,
}));
}
async function handleCopyCurl(profile) { async function handleCopyCurl(profile) {
if (!profile?.id) return; if (!profile?.rawCurl) return;
try { try {
const revealData = await ensureRevealData(profile.id); if (!navigator?.clipboard?.writeText) {
if (!revealData?.rawCurl) return; throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.writeText(revealData.rawCurl); await navigator.clipboard.writeText(profile.rawCurl);
setSuccess(`Copied ${profile.name} cURL.`); setCopiedProfileId(profile.id);
} catch (err) {
setError(err.response?.data?.error || 'Failed to copy the cURL command.'); if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = window.setTimeout(() => {
setCopiedProfileId('');
}, 1800);
} catch {
setError('Failed to copy the cURL command.');
} }
} }
async function handleSave(event) { async function handleSave(event) {
event.preventDefault(); event.preventDefault();
if (!selectedProfile?.id) return; if (!selectedProfile?.id) return;
setSaving(true); setSaving(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
try { if (form.senderId && !/^[A-Za-z]{6}$/.test(form.senderId)) {
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues); setError('DLT Sender ID must be exactly 6 alphabet characters');
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload); setSaving(false);
return;
}
await loadProfiles({ background: true }); try {
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, {
provider: {
providerName: form.providerName,
senderId: form.senderId.toUpperCase(),
dltEntityId: form.dltEntityId,
authKey: form.authKey,
},
});
await loadProfiles();
await refreshOnboardingState(businessId).catch(() => null); await refreshOnboardingState(businessId).catch(() => null);
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`); setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
} catch (err) { } catch (err) {
@ -380,29 +272,21 @@ export default function Providers() {
} }
} }
if (initialLoading) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex items-center justify-center h-64">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="mx-auto max-w-6xl space-y-6 pb-12"> <div className="mx-auto max-w-7xl space-y-6 pb-12">
<div className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1> <h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</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 profiles
</span>
)}
</div>
<p className="mt-1 text-sm font-medium text-gray-500"> <p className="mt-1 text-sm font-medium text-gray-500">
Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL. Pick a saved profile to review its complete request and manage the provider details stored against it.
</p> </p>
</div> </div>
<button <button
@ -415,24 +299,23 @@ export default function Providers() {
</div> </div>
{error && ( {error && (
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700"> <div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
{error} {error}
<button type="button" onClick={() => setError('')} className="font-bold text-gray-600 hover:text-gray-700"> <button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
&times;
</button>
</div> </div>
)} )}
{success && ( {success && (
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700"> <div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
{success} {success}
<button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-600 hover:text-gray-700"> <button onClick={() => setSuccess('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
&times;
</button>
</div> </div>
)} )}
{!selectedProfile ? ( <div
ref={layoutRef}
className={`grid gap-4 ${isDesktopSplit ? 'items-start' : ''}`}
style={isDesktopSplit ? { gridTemplateColumns: `${listPaneWidth}px 12px minmax(0, 1fr)` } : undefined}
>
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"> <section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-200 px-5 py-4"> <div className="border-b border-gray-200 px-5 py-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@ -460,23 +343,27 @@ export default function Providers() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="p-3"> <div className="max-h-[70vh] overflow-y-auto overscroll-contain p-3">
<div className="space-y-3"> <div className="space-y-3">
{profiles.map((profile) => { {profiles.map((profile) => {
const isActive = profile.id === activeProfileId; const isActive = profile.id === activeProfileId;
const complete = profile.executionReadiness?.isSetupComplete === true; const isSelected = profile.id === selectedProfileId;
const complete = isProviderSetupComplete(profile);
return ( return (
<button <button
key={profile.id} key={profile.id}
type="button" type="button"
onClick={() => handleSelectProfile(profile.id)} onClick={() => handleSelectProfile(profile.id)}
className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left transition hover:border-primary-blue hover:bg-gray-50" className={`w-full rounded-xl border p-4 text-left transition ${isSelected
? 'border-primary-blue bg-indigo-50/40 shadow-sm'
: 'border-gray-200 bg-white hover:border-primary-blue hover:bg-gray-50'
}`}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className={`truncate text-sm font-semibold ${isPendingSenderIdProfile(profile) ? 'text-error-text' : 'text-gray-900'}`}>{profile.name}</p> <p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p>
<p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p> <p className="mt-1 text-sm text-gray-500">{buildProviderSummary(profile)}</p>
</div> </div>
<div className="flex shrink-0 flex-wrap justify-end gap-2"> <div className="flex shrink-0 flex-wrap justify-end gap-2">
{isActive && ( {isActive && (
@ -500,20 +387,34 @@ export default function Providers() {
</div> </div>
)} )}
</section> </section>
) : (
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm"> {isDesktopSplit && (
<div className="border-b border-gray-200 px-6 py-5"> <div className="hidden self-stretch lg:flex items-stretch justify-center">
<div className="flex flex-col gap-4">
<button <button
type="button" type="button"
onClick={handleReturnToList} onMouseDown={handleResizeStart}
className="inline-flex w-fit items-center gap-2 text-sm font-semibold text-gray-500 transition hover:text-primary-blue" className="group relative flex h-full w-3 cursor-col-resize items-stretch justify-center bg-transparent"
aria-label="Resize provider profile list"
> >
<span>Saved Profiles</span> <span className="pointer-events-none absolute inset-y-2 left-1/2 w-[0.5px] -translate-x-1/2 rounded-full bg-gray-300 transition group-hover:bg-primary-blue" />
<span className="text-gray-300">/</span>
<span className="text-gray-900">{selectedProfile.name}</span>
</button> </button>
</div>
)}
<section className="min-w-0 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
{!selectedProfile ? (
<div className="flex min-h-[520px] items-center justify-center px-6 py-10 text-center">
<div className="max-w-md">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-400">Select a profile</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-gray-900">Choose a saved profile to review</h2>
<p className="mt-3 text-sm leading-relaxed text-gray-500">
The selected profile will open here with a complete cURL preview, provider details, and activation controls.
</p>
</div>
</div>
) : (
<>
<div className="border-b border-gray-200 px-6 py-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -523,10 +424,10 @@ export default function Providers() {
Active profile Active profile
</span> </span>
)} )}
<ProfileStatusPill complete={selectedProfile.executionReadiness?.isSetupComplete === true} /> <ProfileStatusPill complete={isProviderSetupComplete(selectedProfile)} />
</div> </div>
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
The stored cURL is immutable after validation. You can review it, reveal it, and update the profile fields it depends on. Review the exact saved request, then update the provider fields tied to this profile.
</p> </p>
</div> </div>
@ -540,194 +441,149 @@ export default function Providers() {
Set Active Set Active
</button> </button>
)} )}
<button
type="button"
onClick={() => handleToggleReveal(selectedProfile)}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
>
{showSecretsByProfileId[selectedProfile.id] ? 'Hide Values' : 'Reveal Values'}
</button>
<button <button
type="button" type="button"
onClick={() => handleCopyCurl(selectedProfile)} onClick={() => handleCopyCurl(selectedProfile)}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue" className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
> >
Copy cURL {copiedProfileId === selectedProfile.id ? 'Copied' : 'Copy cURL'}
</button>
<button
type="button"
onClick={() => navigate(`${globalSmsPath}?editProfile=${encodeURIComponent(selectedProfile.id)}`)}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
>
Edit cURL
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-6 px-6 py-6"> <div className="space-y-6 px-6 py-6">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px] xl:items-start"> <div className="rounded-2xl border border-gray-200 bg-gray-950 overflow-hidden">
<div className="overflow-hidden rounded-[28px] border border-slate-200 bg-slate-950 shadow-[0_28px_60px_-42px_rgba(15,23,42,0.75)]"> <div className="flex items-center justify-between gap-4 border-b border-gray-800 px-4 py-3">
<div className="border-b border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.2),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.96))] px-5 py-5"> <div>
<div className="flex flex-wrap items-start gap-3"> <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Preview</p>
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
{selectedCurlView.method}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">
{selectedCurlView.url || 'Endpoint not detected from stored cURL'}
</p>
<p className="mt-1 text-xs font-medium text-slate-400">
{isSelectedProfileRevealed
? 'Saved values are currently rendered inside this request preview.'
: 'Sensitive values stay masked until you explicitly reveal them.'}
</p>
</div> </div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300"> <span className="rounded-full border border-gray-700 bg-gray-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-300">
Updated {formatUpdatedAt(selectedProfile.updatedAt)} Updated {formatUpdatedAt(selectedProfile.updatedAt)}
</span> </span>
</div> </div>
<pre className="max-h-72 overflow-y-auto overscroll-contain whitespace-pre-wrap break-all px-4 py-4 text-xs leading-relaxed text-gray-100">
<code>{selectedProfile.rawCurl}</code>
</pre>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_260px]">
<form onSubmit={handleSave} className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
<div className="border-b border-gray-200 px-5 py-4">
<p className="text-sm font-semibold text-gray-900">Provider Details</p>
<p className="mt-1 text-sm text-gray-500">
These fields are stored against this profile and are used during template publishing.
</p>
</div> </div>
<div className="space-y-5 px-5 py-5"> <div className="space-y-5 px-5 py-5">
<div> <div>
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shell View</p> <label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}>
<pre className="max-h-[26rem] overflow-y-auto overscroll-contain rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-[13px] leading-7 text-slate-100 shadow-inner"> Provider Name {!form.providerName && <span className="text-error-text">*</span>}
<code>{selectedCurlView.command || 'No cURL stored.'}</code>
</pre>
</div>
{selectedCurlView.payload && (
<div>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Request Payload</p>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
{selectedCurlView.payloadFormat === 'json' ? 'JSON' : 'Text'}
</span>
</div>
<pre className="max-h-[22rem] overflow-y-auto overscroll-contain rounded-2xl border border-emerald-400/10 bg-emerald-400/5 px-4 py-4 text-[13px] leading-7 text-emerald-50 shadow-inner">
<code>{selectedCurlView.payload}</code>
</pre>
</div>
)}
</div>
</div>
<aside className="space-y-4">
<div className="rounded-[28px] border border-gray-200 bg-gray-50 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-gray-900">Profile Inspector</p>
<p className="mt-1 text-sm text-gray-500">
Keep the request front and center, then reveal stored values only when you need to inspect or edit them.
</p>
</div>
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${isSelectedProfileRevealed ? 'border-indigo-200 bg-indigo-50 text-primary-dark' : 'border-gray-200 bg-white text-gray-500'}`}>
{isSelectedProfileRevealed ? 'Values visible' : 'Values hidden'}
</span>
</div>
<div className="mt-4 grid gap-3">
<InspectorRow
label="Profile State"
value={selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
/>
<InspectorRow
label="Provider"
value={selectedProfile.provider?.providerName || selectedProfile.curlAnalysis?.providerName || 'Awaiting provider name'}
/>
<InspectorRow
label="Endpoint Host"
value={selectedCurlView.host || 'Not detected'}
/>
<InspectorRow
label="Auth Mode"
value={selectedProfile.curlAnalysis?.authMode || 'Not detected'}
/>
<InspectorRow
label="Profile Fields"
value={`${selectedProfileInputs.length} stored value${selectedProfileInputs.length === 1 ? '' : 's'}`}
/>
<InspectorRow
label="Setup"
value={selectedProfile.executionReadiness?.isSetupComplete
? 'All required profile inputs are complete.'
: `${missingInputCount} required field${missingInputCount === 1 ? '' : 's'} still missing`}
valueClassName={selectedProfile.executionReadiness?.isSetupComplete ? '' : 'text-amber-700'}
/>
</div>
{curlWarnings.length > 0 && (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">Warnings</p>
<ul className="mt-2 space-y-2 text-sm text-amber-900">
{curlWarnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</div>
{isSelectedProfileRevealed ? (
<form onSubmit={handleSave} className="overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-200 px-5 py-4">
<p className="text-sm font-semibold text-gray-900">Stored Profile Values</p>
<p className="mt-1 text-sm text-gray-500">
These fields appear only in reveal mode and stay tied to this immutable cURL profile.
</p>
</div>
<div className="space-y-4 px-5 py-5">
{selectedProfileInputs.length > 0 ? selectedProfileInputs.map((input) => (
<div key={input.key} className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4">
<label className={`mb-1.5 block text-sm font-semibold ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'text-error-text' : 'text-gray-900'}`}>
{input.label} {input.required ? <span className="text-error-text">*</span> : null}
</label> </label>
<input <input
type="text" type="text"
value={formValues[input.key] || ''} value={form.providerName}
onChange={(event) => setFormValues((current) => ({ onChange={(event) => handleChange('providerName', event.target.value)}
...current, className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.providerName ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary placeholder-placeholder-bg font-medium focus:outline-none focus:ring-2 focus:border-transparent transition text-sm`}
[input.key]: input.key === 'senderId' placeholder="e.g. MSG91, Gupshup"
? event.target.value.toUpperCase()
: event.target.value,
}))}
className={`w-full rounded-xl border bg-white px-4 py-2.5 text-sm font-medium text-gray-900 transition focus:border-transparent focus:outline-none focus:ring-2 ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'border-error-text focus:ring-error-text' : 'border-gray-200 focus:ring-primary-blue'}`}
placeholder={input.label}
/> />
<p className="mt-2 text-xs font-medium text-gray-500">
{input.secret
? 'Sensitive value revealed for this inspection session.'
: input.source === 'embedded'
? 'Extracted from the accepted cURL and stored against this profile.'
: 'Stored on the profile before publish and runtime sends continue.'}
</p>
</div> </div>
)) : (
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-sm text-gray-600"> <div className="grid gap-5 sm:grid-cols-2">
No profile-level stored values were extracted from this cURL. <div>
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}>
DLT Sender ID {!form.senderId && <span className="text-error-text">*</span>}
</label>
<input
type="text"
value={form.senderId}
onChange={(event) => handleChange('senderId', event.target.value.toUpperCase())}
maxLength={6}
className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.senderId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono tracking-widest placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm uppercase`}
placeholder="6 CHARS"
/>
<p className="mt-2 text-xs font-medium text-gray-500">Exactly 6 alphabetic characters.</p>
</div>
<div>
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.dltEntityId ? 'text-error-text' : 'text-text-primary'}`}>
DLT Entity ID {!form.dltEntityId && <span className="text-error-text">*</span>}
</label>
<input
type="text"
value={form.dltEntityId}
onChange={(event) => handleChange('dltEntityId', event.target.value)}
className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.dltEntityId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm`}
placeholder="19-digit DLT PE ID"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-text-primary mb-1.5 tracking-wide">
API Auth Key <span className="text-text-muted font-normal text-xs">(Optional)</span>
</label>
<input
type="password"
value={form.authKey}
onChange={(event) => handleChange('authKey', event.target.value)}
className="w-full px-4 py-2 rounded-lg bg-surface-white border border-border-main text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition text-sm"
placeholder="Authorization key for your SMS provider"
/>
<p className="mt-2 text-xs font-medium text-gray-500">Used as the Authorization header in your SMS requests.</p>
</div> </div>
)}
</div> </div>
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4"> <div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50" className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm transition disabled:opacity-50 flex items-center justify-center gap-2"
> >
{saving ? 'Saving…' : 'Save Changes'} {saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving</> : 'Save Configuration'}
</button> </button>
</div> </div>
</form> </form>
) : (
<div className="rounded-[28px] border border-dashed border-gray-300 bg-white px-5 py-5"> <aside className="rounded-2xl border border-gray-200 bg-gray-50 p-5">
<p className="text-sm font-semibold text-gray-900">Values stay hidden by default</p> <p className="text-sm font-semibold text-gray-900">Current Status</p>
<p className="mt-2 text-sm leading-relaxed text-gray-500"> <ul className="mt-4 space-y-3 text-sm">
Reveal mode will render stored values inside the cURL on the left and open the editable field inspector here. <li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Profile State</p>
<p className="mt-2 font-medium text-gray-900">
{selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
</p> </p>
</div> </li>
)} <li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Provider Setup</p>
<p className="mt-2 font-medium text-gray-900">
{isProviderSetupComplete(selectedProfile)
? 'All mandatory provider fields are complete.'
: getMissingProviderFields(selectedProfile).join(', ')}
</p>
</li>
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Auth Key</p>
<p className="mt-2 font-medium text-gray-900">
{selectedProfile.provider?.authKey ? 'Saved on this profile' : 'Not added'}
</p>
</li>
</ul>
</aside> </aside>
</div> </div>
</div> </div>
</section> </>
)} )}
</section>
</div>
</div> </div>
); );
} }

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);
} else {
setInitialLoading(true);
setError(''); 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