Compare commits
No commits in common. "7a34b5010708982b5fcd211501b6cf8c910fad49" and "4f9fd366109a00812ae72ffd494cbbab59b139b3" have entirely different histories.
7a34b50107
...
4f9fd36610
|
|
@ -11,8 +11,6 @@ FROM node:20-alpine
|
|||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
app: sms-extension-backend
|
||||
region: asia-south1
|
||||
|
||||
build:
|
||||
dockerfile: "Dockerfile"
|
||||
ignorefile: ".gitignore"
|
||||
|
|
@ -5,13 +5,14 @@ import apiClient from './api/client';
|
|||
import BusinessReviewModal from './components/BusinessReviewModal';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Businesses from './pages/Businesses';
|
||||
import Providers from './pages/Providers';
|
||||
import GlobalSms from './pages/GlobalSms';
|
||||
import Analytics from './pages/Analytics';
|
||||
import Events from './pages/Events';
|
||||
import Templates from './pages/Templates';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function SubLayout({ children }) {
|
||||
const { activeBusiness, activeBusinessId } = useBusiness();
|
||||
const { activeBusiness, activeBusinessId, hasGlobalSms } = useBusiness();
|
||||
const [reviewBusiness, setReviewBusiness] = useState(null);
|
||||
const [reviewLoading, setReviewLoading] = useState(false);
|
||||
const [reviewError, setReviewError] = useState('');
|
||||
|
|
@ -46,7 +47,17 @@ function SubLayout({ children }) {
|
|||
reviewError={reviewError}
|
||||
/>
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -94,12 +105,12 @@ export default function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Businesses />} />
|
||||
<Route path="/:businessId/settings" element={
|
||||
<BusinessGuard><SubLayout><Providers /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/global-sms" element={
|
||||
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/analytics" element={
|
||||
<BusinessGuard><SubLayout><Analytics /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
<Route path="/:businessId/events" element={
|
||||
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
||||
} />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
import { getBusinessImage } from '../utils/businessProfile';
|
||||
|
||||
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: (
|
||||
<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" />
|
||||
|
|
@ -65,28 +59,54 @@ function StageMarker({ done, active, enabled }) {
|
|||
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 = '' }) {
|
||||
const {
|
||||
activeBusiness,
|
||||
activeBusinessId,
|
||||
clearBusiness,
|
||||
hasGlobalSms,
|
||||
isSetupComplete,
|
||||
hasSelectedTemplates,
|
||||
} = useBusiness();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const businessImage = getBusinessImage(activeBusiness);
|
||||
const businessImage = getSidebarBusinessImage(activeBusiness);
|
||||
|
||||
const analyticsPath = `/${activeBusinessId}/analytics`;
|
||||
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
||||
const eventsPath = `/${activeBusinessId}/events`;
|
||||
const templatesPath = `/${activeBusinessId}/templates`;
|
||||
|
||||
const isAnalyticsRoute = location.pathname === analyticsPath;
|
||||
const isGlobalSmsRoute = location.pathname === globalSmsPath;
|
||||
const isEventsRoute = location.pathname === eventsPath;
|
||||
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 = [
|
||||
{
|
||||
id: 'globalSms',
|
||||
|
|
@ -95,7 +115,8 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
|||
enabled: true,
|
||||
done: isSetupComplete && !isGlobalSmsRoute,
|
||||
active: isGlobalSmsRoute,
|
||||
expanded: false,
|
||||
expanded: isGlobalSmsRoute,
|
||||
substeps: omniSubsteps,
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
|
|
@ -185,27 +206,6 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
|||
|
||||
{/* Nav */}
|
||||
<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">
|
||||
{stepItems.map((item, index) => (
|
||||
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">
|
||||
|
|
|
|||
|
|
@ -126,10 +126,6 @@ export default function TemplateDetailWorkspaceModal({
|
|||
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
|
||||
const provider = boundProfile?.provider || {};
|
||||
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 (
|
||||
<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">
|
||||
<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">
|
||||
Deterministic sample render
|
||||
Sample render
|
||||
</span>
|
||||
</div>
|
||||
<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.'}
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -265,29 +254,6 @@ export default function TemplateDetailWorkspaceModal({
|
|||
</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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -83,19 +83,6 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
|||
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
|
||||
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
||||
</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 && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>
|
||||
|
|
|
|||
|
|
@ -1,96 +1,82 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||
|
||||
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||
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 getInitialValues(inputs = []) {
|
||||
return inputs.reduce((accumulator, input) => {
|
||||
accumulator[input.key] = input.value || '';
|
||||
return accumulator;
|
||||
}, {});
|
||||
function getMissingProviderFields(profile) {
|
||||
const provider = profile?.provider || {};
|
||||
const missing = [];
|
||||
if (!provider.providerName) missing.push('providerName');
|
||||
if (!provider.senderId) missing.push('senderId');
|
||||
if (!provider.dltEntityId) missing.push('dltEntityId');
|
||||
return missing;
|
||||
}
|
||||
|
||||
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
|
||||
const [profile, setProfile] = useState(boundProfile);
|
||||
const [profileForm, setProfileForm] = useState({});
|
||||
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [toNumber, setToNumber] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [step, setStep] = useState('profile');
|
||||
|
||||
const missingInputs = useMemo(
|
||||
() => profile?.executionReadiness?.missingProfileInputs || [],
|
||||
[profile],
|
||||
);
|
||||
const [step, setStep] = useState('provider');
|
||||
|
||||
useEffect(() => {
|
||||
setProfile(boundProfile);
|
||||
setProviderForm({
|
||||
providerName: boundProfile?.provider?.providerName || '',
|
||||
senderId: boundProfile?.provider?.senderId || '',
|
||||
dltEntityId: boundProfile?.provider?.dltEntityId || '',
|
||||
});
|
||||
}, [boundProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileForm(getInitialValues(missingInputs));
|
||||
}, [missingInputs]);
|
||||
const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boundProfile) {
|
||||
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
||||
setStep('profile');
|
||||
setStep('provider');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setStep(missingInputs.length > 0 ? 'profile' : 'publish');
|
||||
}, [boundProfile, missingInputs]);
|
||||
setStep(missingFields.length > 0 ? 'provider' : 'publish');
|
||||
}, [boundProfile, missingFields]);
|
||||
|
||||
async function handleProfileSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!profile?.id || missingInputs.length === 0) return;
|
||||
async function handleProviderSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!profile?.id) return;
|
||||
|
||||
setSavingProfile(true);
|
||||
setSavingProvider(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const payload = buildProfilePatchPayload(missingInputs, profileForm);
|
||||
const res = await apiClient.patch(
|
||||
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
||||
payload,
|
||||
{
|
||||
provider: {
|
||||
providerName: providerForm.providerName,
|
||||
senderId: providerForm.senderId.toUpperCase(),
|
||||
dltEntityId: providerForm.dltEntityId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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) {
|
||||
setError(err.response?.data?.error || 'Failed to save required profile fields');
|
||||
setError(err.response?.data?.error || 'Failed to save provider details');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
setSavingProvider(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish(event) {
|
||||
event.preventDefault();
|
||||
async function handlePublish(e) {
|
||||
e.preventDefault();
|
||||
if (!templateId.trim() || !toNumber.trim()) return;
|
||||
|
||||
setPublishing(true);
|
||||
|
|
@ -104,8 +90,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
await Promise.resolve(onSuccess());
|
||||
} catch (err) {
|
||||
if (err.response?.data?.missingFields?.length) {
|
||||
setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`);
|
||||
setStep('profile');
|
||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
||||
setStep('provider');
|
||||
} else {
|
||||
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;
|
||||
|
||||
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="my-auto w-full max-w-md rounded-lg border border-border-main bg-surface-white p-5">
|
||||
<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="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="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-1 text-center text-lg font-bold text-text-primary">
|
||||
{step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'}
|
||||
<h3 className="text-lg font-bold text-text-primary text-center mb-1">
|
||||
{step === 'provider' ? 'Complete Provider Details' : 'Publish Template'}
|
||||
</h3>
|
||||
<p className="mb-1 text-center text-sm text-text-muted">
|
||||
{step === 'profile'
|
||||
? 'Complete the required fields on the bound cURL profile before publishing.'
|
||||
<p className="text-sm text-text-muted text-center mb-1">
|
||||
{step === 'provider'
|
||||
? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.'
|
||||
: 'Provide the DLT template ID and destination number to complete publish.'}
|
||||
</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, ' ')}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'profile' ? (
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
{missingInputs.map((input) => (
|
||||
<div key={input.key}>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
|
||||
{step === 'provider' ? (
|
||||
<form onSubmit={handleProviderSubmit} className="space-y-4">
|
||||
{missingFields.includes('providerName') && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
|
||||
<input
|
||||
type={input.secret ? 'password' : 'text'}
|
||||
value={profileForm[input.key] || ''}
|
||||
onChange={(event) => setProfileForm((current) => ({
|
||||
...current,
|
||||
[input.key]: input.key === 'senderId'
|
||||
? event.target.value.toUpperCase()
|
||||
: event.target.value,
|
||||
}))}
|
||||
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}
|
||||
type="text"
|
||||
value={providerForm.providerName}
|
||||
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
|
||||
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"
|
||||
placeholder="e.g. MSG91"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={savingProfile}
|
||||
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"
|
||||
disabled={savingProvider}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingProfile || isProfileMissing || missingInputs.some((input) => !String(profileForm[input.key] || '').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"
|
||||
disabled={savingProvider || isProfileMissing || missingFields.some(field => {
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePublish} className="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
value={templateId}
|
||||
onChange={(event) => setTemplateId(event.target.value)}
|
||||
onChange={e => setTemplateId(e.target.value)}
|
||||
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
|
||||
required
|
||||
/>
|
||||
</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
|
||||
type="text"
|
||||
value={toNumber}
|
||||
onChange={(event) => setToNumber(event.target.value)}
|
||||
onChange={e => setToNumber(e.target.value)}
|
||||
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
|
||||
/>
|
||||
<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 className="flex gap-3 pt-4">
|
||||
|
|
@ -219,16 +234,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
|||
type="button"
|
||||
onClick={onClose}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ export function BusinessProvider({ children }) {
|
|||
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => {
|
||||
const updateReadyState = useCallback((activeProfile, templates = []) => {
|
||||
const hasProfile = !!activeProfile;
|
||||
const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride;
|
||||
setHasGlobalSms(hasGlobalSmsProfiles);
|
||||
const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true;
|
||||
setHasGlobalSms(hasProfile);
|
||||
const p = activeProfile?.provider || {};
|
||||
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
|
||||
setIsSetupComplete(nextIsSetupComplete);
|
||||
const nextHasSelectedTemplates = Array.isArray(templates)
|
||||
? templates.some((template) => !!template?.selectedTemplate)
|
||||
|
|
@ -26,7 +26,7 @@ export function BusinessProvider({ children }) {
|
|||
setHasSelectedTemplates(nextHasSelectedTemplates);
|
||||
|
||||
return {
|
||||
hasGlobalSms: hasGlobalSmsProfiles,
|
||||
hasGlobalSms: hasProfile,
|
||||
isSetupComplete: nextIsSetupComplete,
|
||||
hasSelectedTemplates: nextHasSelectedTemplates,
|
||||
};
|
||||
|
|
@ -51,11 +51,7 @@ export function BusinessProvider({ children }) {
|
|||
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
||||
]);
|
||||
|
||||
return updateReadyState(
|
||||
smsRes.data?.activeProfile,
|
||||
templatesRes.data?.templates || [],
|
||||
smsRes.data?.hasProfiles === true,
|
||||
);
|
||||
return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []);
|
||||
}, [activeBusiness?.businessId, updateReadyState]);
|
||||
|
||||
// On mount: rehydrate from sessionStorage and refresh from API
|
||||
|
|
@ -79,11 +75,7 @@ export function BusinessProvider({ children }) {
|
|||
]),
|
||||
]);
|
||||
setActiveBusinessState(bizRes.data);
|
||||
updateReadyState(
|
||||
smsRes[0].data?.activeProfile,
|
||||
smsRes[1].data?.templates || [],
|
||||
smsRes[0].data?.hasProfiles === true,
|
||||
);
|
||||
updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []);
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
|
||||
businessId,
|
||||
companyId: runtimeCompanyId || companyId || '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 apiClient from '../api/client';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
|
@ -157,15 +157,15 @@ function StatusBadge({ status }) {
|
|||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${isScraped
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-amber-100 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
|
||||
}`}
|
||||
/>
|
||||
{isScraped ? 'Onboarded' : 'Not Configured'}
|
||||
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -193,54 +193,30 @@ function UnifiedBusinessCard({
|
|||
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
||||
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
|
||||
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
|
||||
const isCardInteractive = isScraped ? Boolean(item.business) && !isOpening : !isImporting;
|
||||
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();
|
||||
}
|
||||
const canOpenBusiness = isScraped && item.business && !isOpening;
|
||||
|
||||
function handleCardClick() {
|
||||
triggerCardAction();
|
||||
if (!canOpenBusiness) return;
|
||||
onSelect(item.business);
|
||||
}
|
||||
|
||||
function handleCardKeyDown(event) {
|
||||
if (!isCardInteractive) return;
|
||||
if (!canOpenBusiness) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
triggerCardAction();
|
||||
onSelect(item.business);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group rounded-lg bg-white border border-gray-200 transition-all overflow-hidden ${isCardInteractive
|
||||
? 'cursor-pointer hover:border-primary-blue hover:shadow-sm'
|
||||
: 'cursor-default'
|
||||
}`}
|
||||
className={`group rounded-lg bg-white border border-gray-200 transition-all overflow-hidden ${isScraped ? 'cursor-pointer hover:border-primary-blue hover:shadow-sm' : 'hover:border-primary-blue'}`}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
role={isCardInteractive ? 'button' : undefined}
|
||||
tabIndex={isCardInteractive ? 0 : undefined}
|
||||
aria-label={isCardInteractive ? cardActionLabel : undefined}
|
||||
role={isScraped ? 'button' : undefined}
|
||||
tabIndex={isScraped ? 0 : undefined}
|
||||
aria-label={isScraped ? `Open ${name}` : undefined}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
|
@ -307,8 +283,7 @@ function UnifiedBusinessCard({
|
|||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick={() => {
|
||||
if (hasWebsiteUrl) {
|
||||
onImport(item.channel);
|
||||
return;
|
||||
|
|
@ -333,11 +308,9 @@ function UnifiedBusinessCard({
|
|||
export default function Businesses() {
|
||||
const navigate = useNavigate();
|
||||
const { setActiveBusiness } = useBusiness();
|
||||
const hasLoadedBusinessesPageRef = useRef(false);
|
||||
const [businesses, setBusinesses] = useState([]);
|
||||
const [salesChannels, setSalesChannels] = useState([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
|
||||
const [salesChannelQuery, setSalesChannelQuery] = useState('');
|
||||
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
||||
|
|
@ -418,55 +391,37 @@ export default function Businesses() {
|
|||
setBusinesses(res.data.businesses || []);
|
||||
}, []);
|
||||
|
||||
const loadSalesChannels = useCallback(async ({ background = false } = {}) => {
|
||||
if (!background) {
|
||||
setSalesChannelsStatus('loading');
|
||||
}
|
||||
const loadSalesChannels = useCallback(async () => {
|
||||
setSalesChannelsStatus('loading');
|
||||
const channels = await fetchActiveSalesChannels();
|
||||
setSalesChannels(channels);
|
||||
setSalesChannelsStatus('success');
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async ({ background = false } = {}) => {
|
||||
if (background) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setInitialLoading(true);
|
||||
setError('');
|
||||
}
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [businessesRes, salesChannelsRes] = await Promise.allSettled([
|
||||
loadBusinesses(),
|
||||
loadSalesChannels({ background }),
|
||||
loadSalesChannels(),
|
||||
]);
|
||||
|
||||
if (businessesRes.status === 'rejected') {
|
||||
setError('Failed to load businesses');
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
|
||||
if (salesChannelsRes.status === 'rejected') {
|
||||
if (!background) {
|
||||
setSalesChannels([]);
|
||||
setSalesChannelsStatus('error');
|
||||
}
|
||||
if (businessesRes.status !== 'rejected') {
|
||||
setError('Failed to refresh sales channels');
|
||||
}
|
||||
setSalesChannels([]);
|
||||
setSalesChannelsStatus('error');
|
||||
}
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
hasLoadedBusinessesPageRef.current = true;
|
||||
setInitialLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadBusinesses, loadSalesChannels]);
|
||||
|
||||
useEffect(() => { load({ background: hasLoadedBusinessesPageRef.current }); }, [load]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleBusinessCreated = useCallback(async (created) => {
|
||||
setShowModal(false);
|
||||
|
|
@ -474,11 +429,12 @@ export default function Businesses() {
|
|||
setCreatedBusiness(created);
|
||||
|
||||
try {
|
||||
await load({ background: true });
|
||||
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
||||
setSalesChannelsStatus('success');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
|
||||
}
|
||||
}, [load]);
|
||||
}, [loadBusinesses, loadSalesChannels]);
|
||||
|
||||
const handleBusinessJobStarted = useCallback(async (job) => {
|
||||
setError('');
|
||||
|
|
@ -590,7 +546,7 @@ export default function Businesses() {
|
|||
try {
|
||||
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
|
||||
setDeleteTarget(null);
|
||||
await load({ background: true });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete business');
|
||||
} finally {
|
||||
|
|
@ -619,7 +575,7 @@ export default function Businesses() {
|
|||
}
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<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" />
|
||||
|
|
@ -632,17 +588,9 @@ export default function Businesses() {
|
|||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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')}
|
||||
</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>
|
||||
<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')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{showUnifiedSalesChannelView
|
||||
? '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 && (
|
||||
<RegisterBusinessModal
|
||||
onClose={() => { setShowModal(false); load({ background: true }); }}
|
||||
onClose={() => { setShowModal(false); load(); }}
|
||||
onJobStarted={handleBusinessJobStarted}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
|
||||
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
|
||||
const ORDER_PAYMENT_EVENT_SLUGS = [
|
||||
'placed',
|
||||
'payment_failed',
|
||||
];
|
||||
const DELIVERY_EVENT_SLUGS = [
|
||||
const DELIVERY_EVENT_SLUGS = new Set([
|
||||
'out_for_pickup',
|
||||
'bag_picked',
|
||||
'bag_reached_drop_point',
|
||||
'in_transit',
|
||||
'out_for_delivery',
|
||||
'delivery_attempt_failed',
|
||||
'delivery_done',
|
||||
];
|
||||
const CANCELLATION_EVENT_SLUGS = [
|
||||
'handed_over_to_customer',
|
||||
'bag_lost',
|
||||
]);
|
||||
const CANCELLATION_EVENT_SLUGS = new Set([
|
||||
'bag_not_confirmed',
|
||||
'cancelled_at_dp',
|
||||
'cancelled_customer',
|
||||
'cancelled_failed_at_dp',
|
||||
'cancelled_fynd',
|
||||
'rejected_by_customer',
|
||||
];
|
||||
const RETURN_EVENT_SLUGS = [
|
||||
'return_initiated',
|
||||
'return_bag_picked',
|
||||
'return_bag_delivered',
|
||||
];
|
||||
const REFUND_EVENT_SLUGS = [
|
||||
]);
|
||||
const REFUND_EVENT_SLUGS = new Set([
|
||||
'credit_note_generated',
|
||||
'partial_refund_completed',
|
||||
'refund_acknowledged',
|
||||
'refund_approved',
|
||||
'refund_completed',
|
||||
'refund_failed',
|
||||
'refund_initiated',
|
||||
];
|
||||
const CUSTOMER_EVENT_SECTIONS = [
|
||||
'refund_on_hold',
|
||||
'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',
|
||||
label: 'Order & Payment',
|
||||
description: 'Core order confirmation and critical payment updates customers genuinely care about.',
|
||||
slugs: ORDER_PAYMENT_EVENT_SLUGS,
|
||||
id: 'fulfillment',
|
||||
label: 'Order & Fulfillment',
|
||||
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
{
|
||||
id: 'delivery',
|
||||
label: 'Delivery Journey',
|
||||
description: 'The moments that matter most once an order is close to the doorstep.',
|
||||
slugs: DELIVERY_EVENT_SLUGS,
|
||||
description: 'Courier pickup, in-transit updates, and final handover milestones.',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
{
|
||||
id: 'cancellations',
|
||||
label: 'Cancellations & Rejections',
|
||||
description: 'Critical order-stop events that customers should be notified about immediately.',
|
||||
slugs: CANCELLATION_EVENT_SLUGS,
|
||||
description: 'Customer, merchant, and delivery-partner driven cancellations and rejections.',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
{
|
||||
id: 'returns_refunds',
|
||||
label: 'Returns & Refunds',
|
||||
description: 'Only the key return and refund milestones worth notifying customers about.',
|
||||
slugs: [...RETURN_EVENT_SLUGS, ...REFUND_EVENT_SLUGS],
|
||||
id: 'returns',
|
||||
label: 'Returns',
|
||||
description: 'Return initiation, pickup, transit, and merchant-side return handling.',
|
||||
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',
|
||||
label: 'Custom Events',
|
||||
description: 'Business-specific events you added manually for your own messaging flows.',
|
||||
slugs: [],
|
||||
defaultExpanded: false,
|
||||
},
|
||||
];
|
||||
const CUSTOMER_EVENT_SECTION_BY_SLUG = new Map(
|
||||
CUSTOMER_EVENT_SECTIONS.flatMap((section) => section.slugs.map((slug) => [slug, section.id])),
|
||||
);
|
||||
const CUSTOMER_EVENT_SECTION_ORDER = CUSTOMER_EVENT_SECTIONS.reduce((acc, section) => {
|
||||
acc[section.id] = new Map(section.slugs.map((slug, index) => [slug, index]));
|
||||
const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
|
||||
acc[group.id] = group.defaultExpanded;
|
||||
return acc;
|
||||
}, {});
|
||||
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) {
|
||||
return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting';
|
||||
}
|
||||
|
|
@ -116,19 +170,20 @@ function buildSelectedTemplatePreview(template = {}) {
|
|||
variableMap: template?.variableMap && typeof template.variableMap === 'object'
|
||||
? template.variableMap
|
||||
: {},
|
||||
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
|
||||
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
|
||||
? template.executionMeta
|
||||
: {},
|
||||
curlProfileId: String(template?.curlProfileId || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomerFacingSectionId(event) {
|
||||
function getEventGroupId(event) {
|
||||
const slug = String(event?.slug || '');
|
||||
|
||||
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) {
|
||||
|
|
@ -140,33 +195,11 @@ function matchesEventSearch(event, searchTerm) {
|
|||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function sortSectionEvents(sectionId, events) {
|
||||
if (sectionId === 'custom') {
|
||||
return [...events].sort((left, right) => String(left?.label || '').localeCompare(String(right?.label || '')));
|
||||
}
|
||||
|
||||
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 buildGroupedEvents(events, searchTerm) {
|
||||
return EVENT_GROUPS.map((group) => ({
|
||||
...group,
|
||||
events: events.filter((event) => getEventGroupId(event) === group.id && matchesEventSearch(event, searchTerm)),
|
||||
})).filter((group) => group.events.length > 0);
|
||||
}
|
||||
|
||||
function getVariantKey(slug, index) {
|
||||
|
|
@ -209,7 +242,6 @@ function createVariantDraft(text = '') {
|
|||
currentText: text,
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
};
|
||||
}
|
||||
|
|
@ -567,20 +599,9 @@ function TemplateGenerationWorkspaceModal({
|
|||
</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">
|
||||
<p className="font-semibold">Why it did not pass:</p>
|
||||
{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>
|
||||
)}
|
||||
<span className="font-semibold">Why it did not pass:</span> {activeDraft.why}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -657,14 +678,13 @@ function TemplateGenerationWorkspaceModal({
|
|||
export default function Events() {
|
||||
const { businessId } = useParams();
|
||||
const { refreshOnboardingState } = useBusiness();
|
||||
const hasLoadedEventsRef = useRef(false);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [addingEvent, setAddingEvent] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
|
||||
const [genState, setGenState] = useState({});
|
||||
const [variants, setVariants] = useState({});
|
||||
const [variantDrafts, setVariantDrafts] = useState({});
|
||||
|
|
@ -733,12 +753,8 @@ export default function Events() {
|
|||
};
|
||||
}, [templateWorkspace.slug]);
|
||||
|
||||
const loadEvents = useCallback(async ({ background = false } = {}) => {
|
||||
if (background) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setInitialLoading(true);
|
||||
}
|
||||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}/events`),
|
||||
|
|
@ -755,27 +771,21 @@ export default function Events() {
|
|||
} = buildTemplateUiState(templates);
|
||||
|
||||
setEvents(eventsRes.data.events || []);
|
||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl);
|
||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||
setVariants(nextVariants);
|
||||
setGenState(nextGenState);
|
||||
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||||
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
|
||||
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||||
setError('');
|
||||
} catch {
|
||||
setError('Failed to load events');
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
hasLoadedEventsRef.current = true;
|
||||
setInitialLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [businessId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents({ background: hasLoadedEventsRef.current });
|
||||
loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
async function handleAddEvent(e) {
|
||||
|
|
@ -787,7 +797,7 @@ export default function Events() {
|
|||
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
|
||||
setNewLabel('');
|
||||
setShowAddForm(false);
|
||||
await loadEvents({ background: true });
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to add event');
|
||||
} finally {
|
||||
|
|
@ -798,7 +808,7 @@ export default function Events() {
|
|||
async function handleDelete(slug) {
|
||||
try {
|
||||
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
|
||||
await loadEvents({ background: true });
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to delete event');
|
||||
}
|
||||
|
|
@ -1061,7 +1071,6 @@ export default function Events() {
|
|||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: 'checking',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
|
|
@ -1072,24 +1081,12 @@ export default function Events() {
|
|||
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) => ({
|
||||
...currentDrafts,
|
||||
[draftKey]: {
|
||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
||||
why: String(res.data?.why || issues[0]?.message || ''),
|
||||
issues,
|
||||
why: res.data?.why || '',
|
||||
lastCheckedText: editedTemplate,
|
||||
},
|
||||
}));
|
||||
|
|
@ -1101,7 +1098,6 @@ export default function Events() {
|
|||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
|
|
@ -1156,7 +1152,6 @@ export default function Events() {
|
|||
currentText: nextText,
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
issues: [],
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
|
|
@ -1221,7 +1216,14 @@ export default function Events() {
|
|||
handleGenerate(slug, { sessionId });
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
function toggleGroup(groupId) {
|
||||
setExpandedGroups((current) => ({
|
||||
...current,
|
||||
[groupId]: !current[groupId],
|
||||
}));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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" />
|
||||
|
|
@ -1229,8 +1231,8 @@ export default function Events() {
|
|||
);
|
||||
}
|
||||
|
||||
const eventSections = buildVisibleEventSections(events, searchTerm);
|
||||
const totalVisibleEvents = eventSections.reduce((count, section) => count + section.events.length, 0);
|
||||
const groupedEvents = buildGroupedEvents(events, searchTerm);
|
||||
const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.events.length, 0);
|
||||
const workspaceSlug = templateWorkspace.slug;
|
||||
const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null;
|
||||
const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : [];
|
||||
|
|
@ -1257,16 +1259,8 @@ export default function Events() {
|
|||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</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 events
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for customer-facing lifecycle events.</p>
|
||||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
|
||||
</div>
|
||||
<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">
|
||||
|
|
@ -1342,106 +1336,133 @@ export default function Events() {
|
|||
</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 ">
|
||||
<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 className="space-y-8">
|
||||
{eventSections.map((section) => (
|
||||
<section key={section.id} className="space-y-3">
|
||||
<div className="px-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-bold tracking-tight text-gray-800">{section.label}</h2>
|
||||
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{section.events.length} events
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium text-gray-500">{section.description}</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{groupedEvents.map((group) => {
|
||||
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
|
||||
const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
|
||||
|
||||
<div className="space-y-4">
|
||||
{section.events.map((event) => {
|
||||
const state = genState[event.slug] || 'idle';
|
||||
const eventVariants = variants[event.slug] || [];
|
||||
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
||||
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
||||
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
|
||||
const hasSelectedTemplate = !!selectedTemplatePreview;
|
||||
const hasDraftWorkspace = eventVariants.length > 0;
|
||||
const canOpenGenerationWorkspace = hasDraftWorkspace;
|
||||
const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace;
|
||||
|
||||
return (
|
||||
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
|
||||
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{event.isDefault ? (
|
||||
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
||||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(event.slug)}
|
||||
className="mt-0.5 w-6 h-6 rounded-full bg-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 transition shrink-0"
|
||||
title="Delete event"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<span
|
||||
title={statusConfig.label}
|
||||
aria-label={statusConfig.label}
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSelectedTemplate) {
|
||||
handleOpenTemplateViewer(event.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canOpenGenerationWorkspace) {
|
||||
handleOpenTemplateWorkspace(event.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenGenerateWorkspace(event.slug);
|
||||
}}
|
||||
disabled={state === 'loading' || (!hasSelectedTemplate && !canOpenGenerationWorkspace && !readyToGenerate)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasExistingWorkspace
|
||||
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400'
|
||||
: 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating…</>
|
||||
) : hasSelectedTemplate ? (
|
||||
<>View Template</>
|
||||
) : canOpenGenerationWorkspace ? (
|
||||
<>Open Drafts</>
|
||||
) : (
|
||||
<>Generate Template</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
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>
|
||||
</section>
|
||||
))}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
|
||||
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{group.events.length} events
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 text-gray-600 shadow-sm transition group-hover:border-gray-300 group-hover:bg-white group-hover:text-gray-800 ${isExpanded ? 'rotate-180' : ''
|
||||
}`}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
|
||||
<div className="space-y-4">
|
||||
{group.events.map((event) => {
|
||||
const state = genState[event.slug] || 'idle';
|
||||
const eventVariants = variants[event.slug] || [];
|
||||
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
||||
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
||||
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
|
||||
const hasSelectedTemplate = !!selectedTemplatePreview;
|
||||
const hasDraftWorkspace = eventVariants.length > 0;
|
||||
const canOpenGenerationWorkspace = hasDraftWorkspace;
|
||||
const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace;
|
||||
|
||||
return (
|
||||
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
|
||||
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{event.isDefault ? (
|
||||
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
||||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(event.slug)}
|
||||
className="mt-0.5 w-6 h-6 rounded-full bg-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 transition shrink-0"
|
||||
title="Delete event"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<span
|
||||
title={statusConfig.label}
|
||||
aria-label={statusConfig.label}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSelectedTemplate) {
|
||||
handleOpenTemplateViewer(event.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canOpenGenerationWorkspace) {
|
||||
handleOpenTemplateWorkspace(event.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenGenerateWorkspace(event.slug);
|
||||
}}
|
||||
disabled={state === 'loading' || (!hasSelectedTemplate && !canOpenGenerationWorkspace && !readyToGenerate)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasExistingWorkspace
|
||||
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400'
|
||||
: 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating…</>
|
||||
) : hasSelectedTemplate ? (
|
||||
<>View Template</>
|
||||
) : canOpenGenerationWorkspace ? (
|
||||
<>Open Drafts</>
|
||||
) : (
|
||||
<>Generate Template</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -82,11 +82,9 @@ function getTemplateSortRank(template) {
|
|||
export default function Templates() {
|
||||
const { businessId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const hasLoadedTemplatesRef = useRef(false);
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [profilesById, setProfilesById] = useState({});
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
||||
const [testTarget, setTestTarget] = useState(null);
|
||||
|
|
@ -98,13 +96,9 @@ export default function Templates() {
|
|||
const highlightTimeoutRef = useRef(null);
|
||||
const handledFocusSlugRef = useRef('');
|
||||
|
||||
const loadTemplates = useCallback(async ({ background = false } = {}) => {
|
||||
if (background) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setInitialLoading(true);
|
||||
setError('');
|
||||
}
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [templatesRes, profilesRes] = await Promise.all([
|
||||
|
|
@ -117,21 +111,15 @@ export default function Templates() {
|
|||
|
||||
setTemplates(allTemplates);
|
||||
setProfilesById(profileMap);
|
||||
setError('');
|
||||
} catch {
|
||||
setError('Failed to load templates');
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
hasLoadedTemplatesRef.current = true;
|
||||
setInitialLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [businessId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates({ background: hasLoadedTemplatesRef.current });
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
useEffect(() => () => {
|
||||
|
|
@ -191,7 +179,7 @@ export default function Templates() {
|
|||
|
||||
async function handleWhitelistSuccess() {
|
||||
setWhitelistTarget(null);
|
||||
await loadTemplates({ background: true });
|
||||
await loadTemplates();
|
||||
}
|
||||
|
||||
async function handleRuntimeToggle(template) {
|
||||
|
|
@ -219,7 +207,7 @@ export default function Templates() {
|
|||
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
|
||||
: null;
|
||||
|
||||
if (initialLoading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<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" />
|
||||
|
|
@ -230,15 +218,7 @@ export default function Templates() {
|
|||
return (
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<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>
|
||||
{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>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
|
||||
<p className="mt-1 text-sm font-medium text-gray-500">
|
||||
Manage template runtime, whitelisting, and testing from one place.
|
||||
</p>
|
||||
|
|
@ -298,7 +278,7 @@ export default function Templates() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{visibleTemplates.map((template) => {
|
||||
const appearance = getCardAppearance(template);
|
||||
const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null;
|
||||
|
|
@ -317,26 +297,26 @@ export default function Templates() {
|
|||
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
|
||||
? '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="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)}
|
||||
</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 && (
|
||||
<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}
|
||||
</span>
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -348,38 +328,38 @@ export default function Templates() {
|
|||
aria-label={`Set runtime ${isRuntimeEnabled ? 'paused' : 'active'} for ${getTemplateDisplayName(template)}`}
|
||||
disabled={!isPublished || isRuntimeUpdating}
|
||||
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
|
||||
? `${appearance.switchTrackClassName} ${isRuntimeUpdating ? 'cursor-wait opacity-70' : 'cursor-pointer'}`
|
||||
: 'cursor-not-allowed border-[#d8dee8] bg-[#eef1f5] opacity-95'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||
isPublished && isRuntimeEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
isPublished && isRuntimeEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<div className="mt-5 border-t border-gray-100 pt-5">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex flex-wrap items-start gap-8">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-gray-400">Profile</p>
|
||||
<p className="mt-0.5 text-[13px] font-semibold text-gray-900">
|
||||
<p className="text-xs font-medium text-gray-400">Profile</p>
|
||||
<p className="mt-1 text-base font-semibold text-gray-900">
|
||||
{getBoundProfileSummary(template, boundProfile)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] 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="text-xs font-medium text-gray-400">DLT Template ID</p>
|
||||
<p className="mt-1 font-mono text-sm font-semibold text-gray-900">
|
||||
{formatDltTemplateId(template.templateId)}
|
||||
</p>
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setWorkspaceSlug(template.eventSlug)}
|
||||
|
|
@ -392,7 +372,7 @@ export default function Templates() {
|
|||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
|
@ -402,7 +382,7 @@ export default function Templates() {
|
|||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
|
@ -411,7 +391,7 @@ export default function Templates() {
|
|||
</div>
|
||||
|
||||
{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
|
||||
? '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.'}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,6 @@ function firstNonEmptyText(...values) {
|
|||
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) {
|
||||
if (!domain) return '';
|
||||
if (typeof domain === 'string') return normalizeText(domain);
|
||||
|
|
@ -180,14 +170,11 @@ export function getBusinessTagline(entity) {
|
|||
|
||||
export function getBusinessImage(entity) {
|
||||
const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
|
||||
const scrapedLogo = getScrapedLogoUrl(entity);
|
||||
if (relevantImage) return relevantImage;
|
||||
|
||||
return (
|
||||
entity?.logoUrl
|
||||
|| scrapedLogo
|
||||
|| entity?.imageUrl
|
||||
|| entity?.previewImagePath
|
||||
|| relevantImage
|
||||
entity?.imageUrl
|
||||
|| entity?.logoUrl
|
||||
|| entity?.brandImageUrl
|
||||
|| entity?.image
|
||||
|| ''
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { Pool } = require('pg');
|
||||
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) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -6,38 +6,6 @@ function normalizeList(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) {
|
||||
const seen = new Set();
|
||||
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) {
|
||||
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 colorEntries = [];
|
||||
const logos = [];
|
||||
|
|
@ -214,22 +142,12 @@ function flattenBranding(homepage, topImages = []) {
|
|||
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
|
||||
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 {
|
||||
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
|
||||
labeledColors: colorEntries.filter((entry, index, values) => (
|
||||
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
|
||||
)),
|
||||
logos: uniqueStrings(logos),
|
||||
logoCandidates,
|
||||
primaryLogoUrl: logoCandidates[0] || '',
|
||||
brandNames: uniqueStrings(brandNames),
|
||||
};
|
||||
}
|
||||
|
|
@ -270,7 +188,7 @@ function buildCrawlSummary(data = {}, startUrlOverride = '') {
|
|||
...normalizeList(aboutRaw?.images),
|
||||
...productRawPages.flatMap((page) => normalizeList(page?.images)),
|
||||
]).slice(0, 60);
|
||||
const branding = flattenBranding(homepageRaw, topImages);
|
||||
const branding = flattenBranding(homepageRaw);
|
||||
|
||||
return {
|
||||
startUrl,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user