Compare commits
10 Commits
4f9fd36610
...
11dba17f98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11dba17f98 | ||
|
|
cf78cee0db | ||
|
|
d322fbe2d4 | ||
|
|
232d734c98 | ||
|
|
6448ad1e32 | ||
|
|
f3fcc1ef51 | ||
|
|
b80d9404c4 | ||
|
|
5508f094a5 | ||
|
|
3e40ca168a | ||
|
|
e2d3cfb327 |
|
|
@ -11,6 +11,8 @@ FROM node:20-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
app: sms-extension-backend
|
|
||||||
region: asia-south1
|
|
||||||
|
|
||||||
build:
|
|
||||||
dockerfile: "Dockerfile"
|
|
||||||
ignorefile: ".gitignore"
|
|
||||||
|
|
@ -5,14 +5,13 @@ import apiClient from './api/client';
|
||||||
import BusinessReviewModal from './components/BusinessReviewModal';
|
import BusinessReviewModal from './components/BusinessReviewModal';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import Businesses from './pages/Businesses';
|
import Businesses from './pages/Businesses';
|
||||||
import Providers from './pages/Providers';
|
|
||||||
import GlobalSms from './pages/GlobalSms';
|
import GlobalSms from './pages/GlobalSms';
|
||||||
|
import Analytics from './pages/Analytics';
|
||||||
import Events from './pages/Events';
|
import Events from './pages/Events';
|
||||||
import Templates from './pages/Templates';
|
import Templates from './pages/Templates';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
function SubLayout({ children }) {
|
function SubLayout({ children }) {
|
||||||
const { activeBusiness, activeBusinessId, hasGlobalSms } = useBusiness();
|
const { activeBusiness, activeBusinessId } = useBusiness();
|
||||||
const [reviewBusiness, setReviewBusiness] = useState(null);
|
const [reviewBusiness, setReviewBusiness] = useState(null);
|
||||||
const [reviewLoading, setReviewLoading] = useState(false);
|
const [reviewLoading, setReviewLoading] = useState(false);
|
||||||
const [reviewError, setReviewError] = useState('');
|
const [reviewError, setReviewError] = useState('');
|
||||||
|
|
@ -47,17 +46,7 @@ function SubLayout({ children }) {
|
||||||
reviewError={reviewError}
|
reviewError={reviewError}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 ml-60 flex flex-col">
|
<main className="flex-1 ml-60 flex flex-col">
|
||||||
<header className="h-16 border-b border-border-main bg-white flex items-center justify-end px-8 z-10 shrink-0">
|
<header className="h-16 border-b border-border-main bg-white shrink-0" />
|
||||||
{hasGlobalSms && (
|
|
||||||
<Link
|
|
||||||
to={`/${activeBusinessId}/settings`}
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-border-soft bg-gray-50 text-gray-500 transition-colors hover:border-gray-300 hover:bg-white hover:text-primary-blue"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<div className="flex-1 p-5 overflow-auto">
|
<div className="flex-1 p-5 overflow-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,12 +94,12 @@ export default function App() {
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Businesses />} />
|
<Route path="/" element={<Businesses />} />
|
||||||
<Route path="/:businessId/settings" element={
|
|
||||||
<BusinessGuard><SubLayout><Providers /></SubLayout></BusinessGuard>
|
|
||||||
} />
|
|
||||||
<Route path="/:businessId/global-sms" element={
|
<Route path="/:businessId/global-sms" element={
|
||||||
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
<BusinessGuard isGlobalSmsRoute><SubLayout><GlobalSms /></SubLayout></BusinessGuard>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/:businessId/analytics" element={
|
||||||
|
<BusinessGuard><SubLayout><Analytics /></SubLayout></BusinessGuard>
|
||||||
|
} />
|
||||||
<Route path="/:businessId/events" element={
|
<Route path="/:businessId/events" element={
|
||||||
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
<BusinessGuard><SubLayout><Events /></SubLayout></BusinessGuard>
|
||||||
} />
|
} />
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useBusiness } from '../context/BusinessContext';
|
import { useBusiness } from '../context/BusinessContext';
|
||||||
|
import { getBusinessImage } from '../utils/businessProfile';
|
||||||
|
|
||||||
const SVG_ICONS = {
|
const SVG_ICONS = {
|
||||||
|
analytics: (
|
||||||
|
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 20V10m5 10V4m5 16v-6M4 20h16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
globalSms: (
|
globalSms: (
|
||||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
|
@ -59,54 +65,28 @@ function StageMarker({ done, active, enabled }) {
|
||||||
return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />;
|
return <span className="inline-block h-3 w-3 rounded-full bg-refresh-active" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeText(value) {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSidebarBusinessImage(business) {
|
|
||||||
const brandingLogos = business?.scrapeArtifacts?.json?.branding?.logos;
|
|
||||||
const primaryLogo = Array.isArray(brandingLogos)
|
|
||||||
? brandingLogos.find((entry) => normalizeText(entry))
|
|
||||||
: '';
|
|
||||||
if (primaryLogo) return primaryLogo;
|
|
||||||
|
|
||||||
return (
|
|
||||||
normalizeText(business?.logoUrl)
|
|
||||||
|| normalizeText(business?.imageUrl)
|
|
||||||
|| (Array.isArray(business?.relevantImagePaths)
|
|
||||||
? business.relevantImagePaths.find((entry) => normalizeText(entry)) || ''
|
|
||||||
: '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) {
|
export default function Sidebar({ onOpenReview, reviewLoading = false, reviewError = '' }) {
|
||||||
const {
|
const {
|
||||||
activeBusiness,
|
activeBusiness,
|
||||||
activeBusinessId,
|
activeBusinessId,
|
||||||
clearBusiness,
|
clearBusiness,
|
||||||
hasGlobalSms,
|
|
||||||
isSetupComplete,
|
isSetupComplete,
|
||||||
hasSelectedTemplates,
|
hasSelectedTemplates,
|
||||||
} = useBusiness();
|
} = useBusiness();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const businessImage = getSidebarBusinessImage(activeBusiness);
|
const businessImage = getBusinessImage(activeBusiness);
|
||||||
|
|
||||||
|
const analyticsPath = `/${activeBusinessId}/analytics`;
|
||||||
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
const globalSmsPath = `/${activeBusinessId}/global-sms`;
|
||||||
const eventsPath = `/${activeBusinessId}/events`;
|
const eventsPath = `/${activeBusinessId}/events`;
|
||||||
const templatesPath = `/${activeBusinessId}/templates`;
|
const templatesPath = `/${activeBusinessId}/templates`;
|
||||||
|
|
||||||
|
const isAnalyticsRoute = location.pathname === analyticsPath;
|
||||||
const isGlobalSmsRoute = location.pathname === globalSmsPath;
|
const isGlobalSmsRoute = location.pathname === globalSmsPath;
|
||||||
const isEventsRoute = location.pathname === eventsPath;
|
const isEventsRoute = location.pathname === eventsPath;
|
||||||
const isTemplatesRoute = location.pathname === templatesPath;
|
const isTemplatesRoute = location.pathname === templatesPath;
|
||||||
|
|
||||||
const omniSubsteps = [
|
|
||||||
{ id: 'profile', label: 'Add / Select Profile', done: hasGlobalSms, active: isGlobalSmsRoute && !hasGlobalSms },
|
|
||||||
{ id: 'validate', label: 'Validate cURL', done: hasGlobalSms, active: false },
|
|
||||||
{ id: 'fields', label: 'Complete Fields', done: isSetupComplete, active: isGlobalSmsRoute && hasGlobalSms && !isSetupComplete },
|
|
||||||
{ id: 'ready', label: 'Ready', done: isSetupComplete, active: isGlobalSmsRoute && isSetupComplete },
|
|
||||||
];
|
|
||||||
|
|
||||||
const stepItems = [
|
const stepItems = [
|
||||||
{
|
{
|
||||||
id: 'globalSms',
|
id: 'globalSms',
|
||||||
|
|
@ -115,8 +95,7 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
||||||
enabled: true,
|
enabled: true,
|
||||||
done: isSetupComplete && !isGlobalSmsRoute,
|
done: isSetupComplete && !isGlobalSmsRoute,
|
||||||
active: isGlobalSmsRoute,
|
active: isGlobalSmsRoute,
|
||||||
expanded: isGlobalSmsRoute,
|
expanded: false,
|
||||||
substeps: omniSubsteps,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'events',
|
id: 'events',
|
||||||
|
|
@ -206,6 +185,27 @@ export default function Sidebar({ onOpenReview, reviewLoading = false, reviewErr
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 px-3 pt-5">
|
<nav className="flex-1 px-3 pt-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
{isSetupComplete ? (
|
||||||
|
<NavLink
|
||||||
|
to={analyticsPath}
|
||||||
|
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold transition-colors ${
|
||||||
|
isAnalyticsRoute
|
||||||
|
? 'bg-gray-100 text-gray-900'
|
||||||
|
: 'text-gray-600 hover:bg-page-bg hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SVG_ICONS.analytics}
|
||||||
|
<span className="flex-1 truncate">Analytics</span>
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-gray-300 cursor-not-allowed select-none">
|
||||||
|
{SVG_ICONS.analytics}
|
||||||
|
<span className="flex-1 truncate">Analytics</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{stepItems.map((item, index) => (
|
{stepItems.map((item, index) => (
|
||||||
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">
|
<div key={item.id} className="relative grid grid-cols-[26px_minmax(0,1fr)] gap-2">
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,10 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
|
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
|
||||||
const provider = boundProfile?.provider || {};
|
const provider = boundProfile?.provider || {};
|
||||||
const samplePayloadText = JSON.stringify(samplePayload, null, 2);
|
const samplePayloadText = JSON.stringify(samplePayload, null, 2);
|
||||||
|
const executionMeta = template?.executionMeta || {};
|
||||||
|
const executionInputCount = Array.isArray(template?.requiredInputs) ? template.requiredInputs.length : 0;
|
||||||
|
const fallbackCount = previewState.fallbackPlaceholders.length;
|
||||||
|
const unresolvedCount = previewState.unresolvedPlaceholders.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
|
||||||
|
|
@ -201,7 +205,7 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-gray-700">Preview</p>
|
<p className="text-sm font-semibold text-gray-700">Preview</p>
|
||||||
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||||||
Sample render
|
Deterministic sample render
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
|
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
|
||||||
|
|
@ -209,6 +213,13 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
|
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{(fallbackCount > 0 || unresolvedCount > 0) && (
|
||||||
|
<p className={`text-xs font-medium ${unresolvedCount > 0 ? 'text-amber-700' : 'text-gray-500'}`}>
|
||||||
|
{unresolvedCount > 0
|
||||||
|
? `${unresolvedCount} placeholder${unresolvedCount === 1 ? '' : 's'} still need explicit mapping.`
|
||||||
|
: `${fallbackCount} placeholder${fallbackCount === 1 ? '' : 's'} used deterministic sample fallback values.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,6 +265,29 @@ export default function TemplateDetailWorkspaceModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-500">Render Strategy</label>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
||||||
|
{executionMeta.renderStrategy === 'deterministic_sample_payload'
|
||||||
|
? 'Deterministic sample payload'
|
||||||
|
: 'Template variable mapping'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-500">Execution Inputs</label>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
||||||
|
{executionInputCount} stored input{executionInputCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-500">Template Variables</label>
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
||||||
|
{Number.isFinite(executionMeta.placeholderCount) ? executionMeta.placeholderCount : 0} placeholder{executionMeta.placeholderCount === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
|
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
|
||||||
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,19 @@ export default function TestSmsModal({ businessId, template, onClose }) {
|
||||||
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
|
{result.success ? '✓ SMS sent successfully!' : '✗ SMS send failed'}
|
||||||
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
{result.statusCode && <span className="ml-2 opacity-60">HTTP {result.statusCode}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{result.renderedContent && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Rendered SMS Content</label>
|
||||||
|
<pre className="p-3 bg-white border border-gray-200 rounded-lg text-xs font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap break-words">
|
||||||
|
{result.renderedContent}
|
||||||
|
</pre>
|
||||||
|
{Array.isArray(result.renderState?.fallbackPlaceholders) && result.renderState.fallbackPlaceholders.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs font-medium text-gray-500">
|
||||||
|
{result.renderState.fallbackPlaceholders.length} placeholder{result.renderState.fallbackPlaceholders.length === 1 ? '' : 's'} used deterministic sample fallback values.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{result.response && (
|
{result.response && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>
|
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Provider Response</label>
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,96 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
function getMissingProviderFields(profile) {
|
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||||
const provider = profile?.provider || {};
|
|
||||||
const missing = [];
|
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||||
if (!provider.providerName) missing.push('providerName');
|
const provider = {};
|
||||||
if (!provider.senderId) missing.push('senderId');
|
const profileInputValues = {};
|
||||||
if (!provider.dltEntityId) missing.push('dltEntityId');
|
|
||||||
return missing;
|
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;
|
||||||
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
|
export default function WhitelistModal({ businessId, template, boundProfile, onClose, onSuccess }) {
|
||||||
const [profile, setProfile] = useState(boundProfile);
|
const [profile, setProfile] = useState(boundProfile);
|
||||||
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
|
const [profileForm, setProfileForm] = useState({});
|
||||||
const [templateId, setTemplateId] = useState('');
|
const [templateId, setTemplateId] = useState('');
|
||||||
const [toNumber, setToNumber] = useState('');
|
const [toNumber, setToNumber] = useState('');
|
||||||
const [savingProvider, setSavingProvider] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [step, setStep] = useState('provider');
|
const [step, setStep] = useState('profile');
|
||||||
|
|
||||||
|
const missingInputs = useMemo(
|
||||||
|
() => profile?.executionReadiness?.missingProfileInputs || [],
|
||||||
|
[profile],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProfile(boundProfile);
|
setProfile(boundProfile);
|
||||||
setProviderForm({
|
|
||||||
providerName: boundProfile?.provider?.providerName || '',
|
|
||||||
senderId: boundProfile?.provider?.senderId || '',
|
|
||||||
dltEntityId: boundProfile?.provider?.dltEntityId || '',
|
|
||||||
});
|
|
||||||
}, [boundProfile]);
|
}, [boundProfile]);
|
||||||
|
|
||||||
const missingFields = useMemo(() => getMissingProviderFields(profile), [profile]);
|
useEffect(() => {
|
||||||
|
setProfileForm(getInitialValues(missingInputs));
|
||||||
|
}, [missingInputs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!boundProfile) {
|
if (!boundProfile) {
|
||||||
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
setError('The cURL profile bound to this template is missing. Re-select the template from Events before publishing.');
|
||||||
setStep('provider');
|
setStep('profile');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setStep(missingFields.length > 0 ? 'provider' : 'publish');
|
setStep(missingInputs.length > 0 ? 'profile' : 'publish');
|
||||||
}, [boundProfile, missingFields]);
|
}, [boundProfile, missingInputs]);
|
||||||
|
|
||||||
async function handleProviderSubmit(e) {
|
async function handleProfileSubmit(event) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
if (!profile?.id) return;
|
if (!profile?.id || missingInputs.length === 0) return;
|
||||||
|
|
||||||
setSavingProvider(true);
|
setSavingProfile(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const payload = buildProfilePatchPayload(missingInputs, profileForm);
|
||||||
const res = await apiClient.patch(
|
const res = await apiClient.patch(
|
||||||
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
`/api/businesses/${businessId}/global-sms/profiles/${profile.id}`,
|
||||||
{
|
payload,
|
||||||
provider: {
|
|
||||||
providerName: providerForm.providerName,
|
|
||||||
senderId: providerForm.senderId.toUpperCase(),
|
|
||||||
dltEntityId: providerForm.dltEntityId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setProfile(res.data);
|
setProfile(res.data);
|
||||||
setProviderForm({
|
setStep(res.data?.executionReadiness?.missingProfileInputs?.length > 0 ? 'profile' : 'publish');
|
||||||
providerName: res.data?.provider?.providerName || '',
|
|
||||||
senderId: res.data?.provider?.senderId || '',
|
|
||||||
dltEntityId: res.data?.provider?.dltEntityId || '',
|
|
||||||
});
|
|
||||||
setStep(getMissingProviderFields(res.data).length > 0 ? 'provider' : 'publish');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to save provider details');
|
setError(err.response?.data?.error || 'Failed to save required profile fields');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingProvider(false);
|
setSavingProfile(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublish(e) {
|
async function handlePublish(event) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
if (!templateId.trim() || !toNumber.trim()) return;
|
if (!templateId.trim() || !toNumber.trim()) return;
|
||||||
|
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
|
|
@ -90,8 +104,8 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
||||||
await Promise.resolve(onSuccess());
|
await Promise.resolve(onSuccess());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.data?.missingFields?.length) {
|
if (err.response?.data?.missingFields?.length) {
|
||||||
setError(`Missing provider fields: ${err.response.data.missingFields.join(', ')}`);
|
setError(`Missing profile fields: ${err.response.data.missingFields.join(', ')}`);
|
||||||
setStep('provider');
|
setStep('profile');
|
||||||
} else {
|
} else {
|
||||||
setError(err.response?.data?.error || 'Failed to publish template');
|
setError(err.response?.data?.error || 'Failed to publish template');
|
||||||
}
|
}
|
||||||
|
|
@ -103,130 +117,101 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
||||||
const isProfileMissing = !profile?.id;
|
const isProfileMissing = !profile?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/50 backdrop-blur-sm overflow-y-auto pt-10 pb-10">
|
<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="bg-surface-white border border-border-main rounded-lg p-5 w-full max-w-md my-auto">
|
<div className="my-auto w-full max-w-md rounded-lg border border-border-main bg-surface-white p-5">
|
||||||
<div className="w-12 h-12 rounded-full bg-white border border-gray-200 flex items-center justify-center mx-auto mb-4">
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-white">
|
||||||
<span className="text-xl">✅</span>
|
<span className="text-xl">✅</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-bold text-text-primary text-center mb-1">
|
<h3 className="mb-1 text-center text-lg font-bold text-text-primary">
|
||||||
{step === 'provider' ? 'Complete Provider Details' : 'Publish Template'}
|
{step === 'profile' ? 'Complete Profile Setup' : 'Publish Template'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-text-muted text-center mb-1">
|
<p className="mb-1 text-center text-sm text-text-muted">
|
||||||
{step === 'provider'
|
{step === 'profile'
|
||||||
? 'Save the missing mandatory provider fields on the bound cURL profile before publishing.'
|
? 'Complete the required fields on the bound cURL profile before publishing.'
|
||||||
: 'Provide the DLT template ID and destination number to complete publish.'}
|
: 'Provide the DLT template ID and destination number to complete publish.'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-semibold text-text-primary text-center mb-2 capitalize">
|
<p className="mb-2 text-center text-sm font-semibold capitalize text-text-primary">
|
||||||
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
{template.eventLabel || template.eventSlug.replace(/_/g, ' ')}
|
||||||
</p>
|
</p>
|
||||||
{profile && (
|
{profile && (
|
||||||
<p className="text-xs text-text-muted text-center mb-6 uppercase tracking-wide font-semibold">
|
<p className="mb-6 text-center text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||||
Bound Profile: {profile.name}
|
Bound Profile: {profile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-4 py-2 rounded-md text-error-text bg-white border border-gray-200 text-sm font-medium">
|
<div className="mb-4 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'provider' ? (
|
{step === 'profile' ? (
|
||||||
<form onSubmit={handleProviderSubmit} className="space-y-4">
|
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||||
{missingFields.includes('providerName') && (
|
{missingInputs.map((input) => (
|
||||||
<div>
|
<div key={input.key}>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Provider Name</label>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type={input.secret ? 'password' : 'text'}
|
||||||
value={providerForm.providerName}
|
value={profileForm[input.key] || ''}
|
||||||
onChange={e => setProviderForm(prev => ({ ...prev, providerName: e.target.value }))}
|
onChange={(event) => setProfileForm((current) => ({
|
||||||
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
...current,
|
||||||
placeholder="e.g. MSG91"
|
[input.key]: input.key === 'senderId'
|
||||||
autoFocus
|
? event.target.value.toUpperCase()
|
||||||
required
|
: 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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{missingFields.includes('senderId') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Sender ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={providerForm.senderId}
|
|
||||||
onChange={e => setProviderForm(prev => ({ ...prev, senderId: e.target.value.toUpperCase() }))}
|
|
||||||
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono uppercase text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
|
||||||
placeholder="6 CHARS"
|
|
||||||
maxLength={6}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{missingFields.includes('dltEntityId') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Entity ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={providerForm.dltEntityId}
|
|
||||||
onChange={e => setProviderForm(prev => ({ ...prev, dltEntityId: e.target.value }))}
|
|
||||||
className="w-full px-4 py-2 rounded-lg bg-page-bg border border-border-main font-mono text-text-primary placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm"
|
|
||||||
placeholder="19-digit DLT PE ID"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={savingProvider}
|
disabled={savingProfile}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={savingProvider || isProfileMissing || missingFields.some(field => {
|
disabled={savingProfile || isProfileMissing || missingInputs.some((input) => !String(profileForm[input.key] || '').trim())}
|
||||||
if (field === 'providerName') return !providerForm.providerName.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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{savingProvider ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving…</> : 'Save Details'}
|
{savingProfile ? 'Saving…' : 'Save Details'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handlePublish} className="space-y-4">
|
<form onSubmit={handlePublish} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">DLT Template ID</label>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">DLT Template ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={templateId}
|
value={templateId}
|
||||||
onChange={e => setTemplateId(e.target.value)}
|
onChange={(event) => setTemplateId(event.target.value)}
|
||||||
placeholder="e.g. 1234567890987654321"
|
placeholder="e.g. 1234567890987654321"
|
||||||
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"
|
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"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5">Destination Phone Number</label>
|
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Destination Phone Number</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={toNumber}
|
value={toNumber}
|
||||||
onChange={e => setToNumber(e.target.value)}
|
onChange={(event) => setToNumber(event.target.value)}
|
||||||
placeholder="e.g. 919876543210"
|
placeholder="e.g. 919876543210"
|
||||||
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"
|
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"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-text-muted mt-1">This sends the publish-triggering SMS request.</p>
|
<p className="mt-1 text-xs text-text-muted">This sends the publish-triggering SMS request.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
|
|
@ -234,16 +219,16 @@ export default function WhitelistModal({ businessId, template, boundProfile, onC
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={publishing}
|
disabled={publishing}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={publishing || !templateId.trim() || !toNumber.trim()}
|
disabled={publishing || !templateId.trim() || !toNumber.trim()}
|
||||||
className="flex-1 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"
|
className="flex-1 rounded-lg bg-primary-blue py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{publishing ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Publishing…</> : 'Publish'}
|
{publishing ? 'Publishing…' : 'Publish'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ export function BusinessProvider({ children }) {
|
||||||
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
|
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const updateReadyState = useCallback((activeProfile, templates = []) => {
|
const updateReadyState = useCallback((activeProfile, templates = [], hasProfilesOverride = false) => {
|
||||||
const hasProfile = !!activeProfile;
|
const hasProfile = !!activeProfile;
|
||||||
setHasGlobalSms(hasProfile);
|
const hasGlobalSmsProfiles = hasProfile || hasProfilesOverride;
|
||||||
const p = activeProfile?.provider || {};
|
setHasGlobalSms(hasGlobalSmsProfiles);
|
||||||
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
|
const nextIsSetupComplete = hasProfile && activeProfile?.executionReadiness?.isSetupComplete === true;
|
||||||
setIsSetupComplete(nextIsSetupComplete);
|
setIsSetupComplete(nextIsSetupComplete);
|
||||||
const nextHasSelectedTemplates = Array.isArray(templates)
|
const nextHasSelectedTemplates = Array.isArray(templates)
|
||||||
? templates.some((template) => !!template?.selectedTemplate)
|
? templates.some((template) => !!template?.selectedTemplate)
|
||||||
|
|
@ -26,7 +26,7 @@ export function BusinessProvider({ children }) {
|
||||||
setHasSelectedTemplates(nextHasSelectedTemplates);
|
setHasSelectedTemplates(nextHasSelectedTemplates);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasGlobalSms: hasProfile,
|
hasGlobalSms: hasGlobalSmsProfiles,
|
||||||
isSetupComplete: nextIsSetupComplete,
|
isSetupComplete: nextIsSetupComplete,
|
||||||
hasSelectedTemplates: nextHasSelectedTemplates,
|
hasSelectedTemplates: nextHasSelectedTemplates,
|
||||||
};
|
};
|
||||||
|
|
@ -51,7 +51,11 @@ export function BusinessProvider({ children }) {
|
||||||
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []);
|
return updateReadyState(
|
||||||
|
smsRes.data?.activeProfile,
|
||||||
|
templatesRes.data?.templates || [],
|
||||||
|
smsRes.data?.hasProfiles === true,
|
||||||
|
);
|
||||||
}, [activeBusiness?.businessId, updateReadyState]);
|
}, [activeBusiness?.businessId, updateReadyState]);
|
||||||
|
|
||||||
// On mount: rehydrate from sessionStorage and refresh from API
|
// On mount: rehydrate from sessionStorage and refresh from API
|
||||||
|
|
@ -75,7 +79,11 @@ export function BusinessProvider({ children }) {
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
setActiveBusinessState(bizRes.data);
|
setActiveBusinessState(bizRes.data);
|
||||||
updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []);
|
updateReadyState(
|
||||||
|
smsRes[0].data?.activeProfile,
|
||||||
|
smsRes[1].data?.templates || [],
|
||||||
|
smsRes[0].data?.hasProfiles === true,
|
||||||
|
);
|
||||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
|
||||||
businessId,
|
businessId,
|
||||||
companyId: runtimeCompanyId || companyId || '',
|
companyId: runtimeCompanyId || companyId || '',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,833 @@
|
||||||
|
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 } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import { useBusiness } from '../context/BusinessContext';
|
import { useBusiness } from '../context/BusinessContext';
|
||||||
|
|
@ -165,7 +165,7 @@ function StatusBadge({ status }) {
|
||||||
className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
|
className={`h-2 w-2 rounded-full ${isScraped ? 'bg-green-500' : 'bg-amber-500'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isScraped ? 'Scraped' : 'Not Scraped Yet'}
|
{isScraped ? 'Onboarded' : 'Not Configured'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -193,30 +193,54 @@ function UnifiedBusinessCard({
|
||||||
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
const isImporting = !isScraped && creatingSalesChannelId === channelId;
|
||||||
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
|
const isLoadingReview = isScraped && reviewLoadingBusinessId === businessId;
|
||||||
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
|
const hasWebsiteUrl = Boolean(item.channel?.websiteUrl);
|
||||||
const canOpenBusiness = isScraped && item.business && !isOpening;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
function handleCardClick() {
|
function handleCardClick() {
|
||||||
if (!canOpenBusiness) return;
|
triggerCardAction();
|
||||||
onSelect(item.business);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardKeyDown(event) {
|
function handleCardKeyDown(event) {
|
||||||
if (!canOpenBusiness) return;
|
if (!isCardInteractive) return;
|
||||||
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSelect(item.business);
|
triggerCardAction();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group rounded-lg bg-white border border-gray-200 transition-all overflow-hidden ${isScraped ? 'cursor-pointer hover:border-primary-blue hover:shadow-sm' : 'hover:border-primary-blue'}`}
|
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'
|
||||||
|
}`}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
onKeyDown={handleCardKeyDown}
|
onKeyDown={handleCardKeyDown}
|
||||||
role={isScraped ? 'button' : undefined}
|
role={isCardInteractive ? 'button' : undefined}
|
||||||
tabIndex={isScraped ? 0 : undefined}
|
tabIndex={isCardInteractive ? 0 : undefined}
|
||||||
aria-label={isScraped ? `Open ${name}` : undefined}
|
aria-label={isCardInteractive ? cardActionLabel : undefined}
|
||||||
>
|
>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|
@ -283,7 +307,8 @@ function UnifiedBusinessCard({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
if (hasWebsiteUrl) {
|
if (hasWebsiteUrl) {
|
||||||
onImport(item.channel);
|
onImport(item.channel);
|
||||||
return;
|
return;
|
||||||
|
|
@ -308,9 +333,11 @@ function UnifiedBusinessCard({
|
||||||
export default function Businesses() {
|
export default function Businesses() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setActiveBusiness } = useBusiness();
|
const { setActiveBusiness } = useBusiness();
|
||||||
|
const hasLoadedBusinessesPageRef = useRef(false);
|
||||||
const [businesses, setBusinesses] = useState([]);
|
const [businesses, setBusinesses] = useState([]);
|
||||||
const [salesChannels, setSalesChannels] = useState([]);
|
const [salesChannels, setSalesChannels] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
|
const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading');
|
||||||
const [salesChannelQuery, setSalesChannelQuery] = useState('');
|
const [salesChannelQuery, setSalesChannelQuery] = useState('');
|
||||||
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
const [selectingBusinessId, setSelectingBusinessId] = useState('');
|
||||||
|
|
@ -391,37 +418,55 @@ export default function Businesses() {
|
||||||
setBusinesses(res.data.businesses || []);
|
setBusinesses(res.data.businesses || []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadSalesChannels = useCallback(async () => {
|
const loadSalesChannels = useCallback(async ({ background = false } = {}) => {
|
||||||
|
if (!background) {
|
||||||
setSalesChannelsStatus('loading');
|
setSalesChannelsStatus('loading');
|
||||||
|
}
|
||||||
const channels = await fetchActiveSalesChannels();
|
const channels = await fetchActiveSalesChannels();
|
||||||
setSalesChannels(channels);
|
setSalesChannels(channels);
|
||||||
setSalesChannelsStatus('success');
|
setSalesChannelsStatus('success');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async ({ background = false } = {}) => {
|
||||||
setLoading(true);
|
if (background) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setInitialLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [businessesRes, salesChannelsRes] = await Promise.allSettled([
|
const [businessesRes, salesChannelsRes] = await Promise.allSettled([
|
||||||
loadBusinesses(),
|
loadBusinesses(),
|
||||||
loadSalesChannels(),
|
loadSalesChannels({ background }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (businessesRes.status === 'rejected') {
|
if (businessesRes.status === 'rejected') {
|
||||||
setError('Failed to load businesses');
|
setError('Failed to load businesses');
|
||||||
|
} else {
|
||||||
|
setError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (salesChannelsRes.status === 'rejected') {
|
if (salesChannelsRes.status === 'rejected') {
|
||||||
|
if (!background) {
|
||||||
setSalesChannels([]);
|
setSalesChannels([]);
|
||||||
setSalesChannelsStatus('error');
|
setSalesChannelsStatus('error');
|
||||||
}
|
}
|
||||||
|
if (businessesRes.status !== 'rejected') {
|
||||||
|
setError('Failed to refresh sales channels');
|
||||||
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (background) {
|
||||||
|
setRefreshing(false);
|
||||||
|
} else {
|
||||||
|
hasLoadedBusinessesPageRef.current = true;
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [loadBusinesses, loadSalesChannels]);
|
}, [loadBusinesses, loadSalesChannels]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load({ background: hasLoadedBusinessesPageRef.current }); }, [load]);
|
||||||
|
|
||||||
const handleBusinessCreated = useCallback(async (created) => {
|
const handleBusinessCreated = useCallback(async (created) => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
|
|
@ -429,12 +474,11 @@ export default function Businesses() {
|
||||||
setCreatedBusiness(created);
|
setCreatedBusiness(created);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([loadBusinesses(), loadSalesChannels()]);
|
await load({ background: true });
|
||||||
setSalesChannelsStatus('success');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
|
setError(err.response?.data?.error || 'Business was created, but the business list could not be refreshed.');
|
||||||
}
|
}
|
||||||
}, [loadBusinesses, loadSalesChannels]);
|
}, [load]);
|
||||||
|
|
||||||
const handleBusinessJobStarted = useCallback(async (job) => {
|
const handleBusinessJobStarted = useCallback(async (job) => {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
@ -546,7 +590,7 @@ export default function Businesses() {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
|
await apiClient.delete(`/api/businesses/${deleteTarget.businessId}`);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
await load();
|
await load({ background: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to delete business');
|
setError(err.response?.data?.error || 'Failed to delete business');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -575,7 +619,7 @@ export default function Businesses() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (initialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||||
|
|
@ -588,9 +632,17 @@ export default function Businesses() {
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">
|
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">
|
||||||
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
|
{showUnifiedSalesChannelView ? 'Your Sales Channels' : (businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business')}
|
||||||
</h1>
|
</h1>
|
||||||
|
{refreshing && (
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
|
||||||
|
Refreshing list
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
{showUnifiedSalesChannelView
|
{showUnifiedSalesChannelView
|
||||||
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
|
? 'View every connected sales channel in one place and onboard the ones that are not scraped yet.'
|
||||||
|
|
@ -707,7 +759,7 @@ export default function Businesses() {
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<RegisterBusinessModal
|
<RegisterBusinessModal
|
||||||
onClose={() => { setShowModal(false); load(); }}
|
onClose={() => { setShowModal(false); load({ background: true }); }}
|
||||||
onJobStarted={handleBusinessJobStarted}
|
onJobStarted={handleBusinessJobStarted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -19,90 +19,67 @@ const SUPPORTED_DLT_VARIABLE_OPTIONS = DLT_VARIABLE_OPTIONS;
|
||||||
const DLT_TOKEN_SET = new Set(SUPPORTED_DLT_VARIABLE_OPTIONS.map((option) => option.token));
|
const DLT_TOKEN_SET = new Set(SUPPORTED_DLT_VARIABLE_OPTIONS.map((option) => option.token));
|
||||||
const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
|
const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
|
||||||
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
|
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
|
||||||
const DELIVERY_EVENT_SLUGS = new Set([
|
const ORDER_PAYMENT_EVENT_SLUGS = [
|
||||||
'out_for_pickup',
|
'placed',
|
||||||
'bag_picked',
|
'payment_failed',
|
||||||
'bag_reached_drop_point',
|
];
|
||||||
'in_transit',
|
const DELIVERY_EVENT_SLUGS = [
|
||||||
'out_for_delivery',
|
'out_for_delivery',
|
||||||
'delivery_attempt_failed',
|
'delivery_attempt_failed',
|
||||||
'delivery_done',
|
'delivery_done',
|
||||||
'handed_over_to_customer',
|
];
|
||||||
'bag_lost',
|
const CANCELLATION_EVENT_SLUGS = [
|
||||||
]);
|
|
||||||
const CANCELLATION_EVENT_SLUGS = new Set([
|
|
||||||
'bag_not_confirmed',
|
|
||||||
'cancelled_at_dp',
|
'cancelled_at_dp',
|
||||||
'cancelled_customer',
|
'cancelled_customer',
|
||||||
'cancelled_failed_at_dp',
|
|
||||||
'cancelled_fynd',
|
|
||||||
'rejected_by_customer',
|
'rejected_by_customer',
|
||||||
]);
|
];
|
||||||
const REFUND_EVENT_SLUGS = new Set([
|
const RETURN_EVENT_SLUGS = [
|
||||||
'credit_note_generated',
|
'return_initiated',
|
||||||
'partial_refund_completed',
|
'return_bag_picked',
|
||||||
'refund_acknowledged',
|
'return_bag_delivered',
|
||||||
'refund_approved',
|
];
|
||||||
|
const REFUND_EVENT_SLUGS = [
|
||||||
'refund_completed',
|
'refund_completed',
|
||||||
'refund_failed',
|
'refund_failed',
|
||||||
'refund_initiated',
|
'refund_initiated',
|
||||||
'refund_on_hold',
|
];
|
||||||
'refund_pending',
|
const CUSTOMER_EVENT_SECTIONS = [
|
||||||
'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: 'fulfillment',
|
id: 'order_payment',
|
||||||
label: 'Order & Fulfillment',
|
label: 'Order & Payment',
|
||||||
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
|
description: 'Core order confirmation and critical payment updates customers genuinely care about.',
|
||||||
defaultExpanded: false,
|
slugs: ORDER_PAYMENT_EVENT_SLUGS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'delivery',
|
id: 'delivery',
|
||||||
label: 'Delivery Journey',
|
label: 'Delivery Journey',
|
||||||
description: 'Courier pickup, in-transit updates, and final handover milestones.',
|
description: 'The moments that matter most once an order is close to the doorstep.',
|
||||||
defaultExpanded: false,
|
slugs: DELIVERY_EVENT_SLUGS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cancellations',
|
id: 'cancellations',
|
||||||
label: 'Cancellations & Rejections',
|
label: 'Cancellations & Rejections',
|
||||||
description: 'Customer, merchant, and delivery-partner driven cancellations and rejections.',
|
description: 'Critical order-stop events that customers should be notified about immediately.',
|
||||||
defaultExpanded: false,
|
slugs: CANCELLATION_EVENT_SLUGS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'returns',
|
id: 'returns_refunds',
|
||||||
label: 'Returns',
|
label: 'Returns & Refunds',
|
||||||
description: 'Return initiation, pickup, transit, and merchant-side return handling.',
|
description: 'Only the key return and refund milestones worth notifying customers about.',
|
||||||
defaultExpanded: false,
|
slugs: [...RETURN_EVENT_SLUGS, ...REFUND_EVENT_SLUGS],
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'refunds',
|
|
||||||
label: 'Refunds',
|
|
||||||
description: 'Refund processing and credit-note states across payment flows.',
|
|
||||||
defaultExpanded: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rto',
|
|
||||||
label: 'RTO',
|
|
||||||
description: 'Return-to-origin movement and completion states after failed delivery.',
|
|
||||||
defaultExpanded: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'custom',
|
id: 'custom',
|
||||||
label: 'Custom Events',
|
label: 'Custom Events',
|
||||||
description: 'Business-specific events you added manually for your own messaging flows.',
|
description: 'Business-specific events you added manually for your own messaging flows.',
|
||||||
defaultExpanded: false,
|
slugs: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
|
const CUSTOMER_EVENT_SECTION_BY_SLUG = new Map(
|
||||||
acc[group.id] = group.defaultExpanded;
|
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]));
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
||||||
|
|
@ -123,37 +100,6 @@ const EVENT_TEMPLATE_STATUS_CONFIG = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const EVENT_GROUP_STYLE_CONFIG = {
|
|
||||||
fulfillment: {
|
|
||||||
markerShell: 'border-slate-200 bg-slate-50',
|
|
||||||
markerDot: 'bg-slate-500',
|
|
||||||
},
|
|
||||||
delivery: {
|
|
||||||
markerShell: 'border-sky-200 bg-sky-50',
|
|
||||||
markerDot: 'bg-sky-500',
|
|
||||||
},
|
|
||||||
cancellations: {
|
|
||||||
markerShell: 'border-rose-200 bg-rose-50',
|
|
||||||
markerDot: 'bg-rose-500',
|
|
||||||
},
|
|
||||||
returns: {
|
|
||||||
markerShell: 'border-indigo-200 bg-indigo-50',
|
|
||||||
markerDot: 'bg-indigo-500',
|
|
||||||
},
|
|
||||||
refunds: {
|
|
||||||
markerShell: 'border-emerald-200 bg-emerald-50',
|
|
||||||
markerDot: 'bg-emerald-500',
|
|
||||||
},
|
|
||||||
rto: {
|
|
||||||
markerShell: 'border-fuchsia-200 bg-fuchsia-50',
|
|
||||||
markerDot: 'bg-fuchsia-500',
|
|
||||||
},
|
|
||||||
custom: {
|
|
||||||
markerShell: 'border-indigo-200 bg-indigo-50',
|
|
||||||
markerDot: 'bg-indigo-500',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeTemplateStatus(status) {
|
function normalizeTemplateStatus(status) {
|
||||||
return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting';
|
return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting';
|
||||||
}
|
}
|
||||||
|
|
@ -170,20 +116,19 @@ function buildSelectedTemplatePreview(template = {}) {
|
||||||
variableMap: template?.variableMap && typeof template.variableMap === 'object'
|
variableMap: template?.variableMap && typeof template.variableMap === 'object'
|
||||||
? template.variableMap
|
? template.variableMap
|
||||||
: {},
|
: {},
|
||||||
|
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
|
||||||
|
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
|
||||||
|
? template.executionMeta
|
||||||
|
: {},
|
||||||
curlProfileId: String(template?.curlProfileId || '').trim(),
|
curlProfileId: String(template?.curlProfileId || '').trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventGroupId(event) {
|
function getCustomerFacingSectionId(event) {
|
||||||
const slug = String(event?.slug || '');
|
const slug = String(event?.slug || '');
|
||||||
|
|
||||||
if (!event?.isDefault) return 'custom';
|
if (!event?.isDefault) return 'custom';
|
||||||
if (slug.startsWith('rto_') || slug === 'return_to_origin') return 'rto';
|
return CUSTOMER_EVENT_SECTION_BY_SLUG.get(slug) || null;
|
||||||
if (slug.startsWith('return_') || RETURN_EVENT_SLUGS.has(slug)) return 'returns';
|
|
||||||
if (slug.startsWith('refund_') || REFUND_EVENT_SLUGS.has(slug)) return 'refunds';
|
|
||||||
if (CANCELLATION_EVENT_SLUGS.has(slug)) return 'cancellations';
|
|
||||||
if (DELIVERY_EVENT_SLUGS.has(slug)) return 'delivery';
|
|
||||||
return 'fulfillment';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesEventSearch(event, searchTerm) {
|
function matchesEventSearch(event, searchTerm) {
|
||||||
|
|
@ -195,11 +140,33 @@ function matchesEventSearch(event, searchTerm) {
|
||||||
.some((value) => String(value).toLowerCase().includes(query));
|
.some((value) => String(value).toLowerCase().includes(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGroupedEvents(events, searchTerm) {
|
function sortSectionEvents(sectionId, events) {
|
||||||
return EVENT_GROUPS.map((group) => ({
|
if (sectionId === 'custom') {
|
||||||
...group,
|
return [...events].sort((left, right) => String(left?.label || '').localeCompare(String(right?.label || '')));
|
||||||
events: events.filter((event) => getEventGroupId(event) === group.id && matchesEventSearch(event, searchTerm)),
|
}
|
||||||
})).filter((group) => group.events.length > 0);
|
|
||||||
|
const orderMap = CUSTOMER_EVENT_SECTION_ORDER[sectionId] || new Map();
|
||||||
|
return [...events].sort((left, right) => {
|
||||||
|
const leftRank = orderMap.get(String(left?.slug || '')) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const rightRank = orderMap.get(String(right?.slug || '')) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
if (leftRank !== rightRank) return leftRank - rightRank;
|
||||||
|
return String(left?.label || '').localeCompare(String(right?.label || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVisibleEventSections(events, searchTerm) {
|
||||||
|
return CUSTOMER_EVENT_SECTIONS.map((section) => {
|
||||||
|
const filteredEvents = events.filter((event) => (
|
||||||
|
getCustomerFacingSectionId(event) === section.id
|
||||||
|
&& matchesEventSearch(event, searchTerm)
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
events: sortSectionEvents(section.id, filteredEvents),
|
||||||
|
};
|
||||||
|
}).filter((section) => section.events.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVariantKey(slug, index) {
|
function getVariantKey(slug, index) {
|
||||||
|
|
@ -242,6 +209,7 @@ function createVariantDraft(text = '') {
|
||||||
currentText: text,
|
currentText: text,
|
||||||
validationStatus: 'idle',
|
validationStatus: 'idle',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -599,9 +567,20 @@ function TemplateGenerationWorkspaceModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{validationStatus === 'rejected' && currentMatchesCheckedText && activeDraft?.why && (
|
{validationStatus === 'rejected' && currentMatchesCheckedText && (activeDraft?.issues?.length > 0 || activeDraft?.why) && (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||||||
<span className="font-semibold">Why it did not pass:</span> {activeDraft.why}
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -678,13 +657,14 @@ function TemplateGenerationWorkspaceModal({
|
||||||
export default function Events() {
|
export default function Events() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const { refreshOnboardingState } = useBusiness();
|
const { refreshOnboardingState } = useBusiness();
|
||||||
|
const hasLoadedEventsRef = useRef(false);
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [newLabel, setNewLabel] = useState('');
|
const [newLabel, setNewLabel] = useState('');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [addingEvent, setAddingEvent] = useState(false);
|
const [addingEvent, setAddingEvent] = useState(false);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
|
|
||||||
const [genState, setGenState] = useState({});
|
const [genState, setGenState] = useState({});
|
||||||
const [variants, setVariants] = useState({});
|
const [variants, setVariants] = useState({});
|
||||||
const [variantDrafts, setVariantDrafts] = useState({});
|
const [variantDrafts, setVariantDrafts] = useState({});
|
||||||
|
|
@ -753,8 +733,12 @@ export default function Events() {
|
||||||
};
|
};
|
||||||
}, [templateWorkspace.slug]);
|
}, [templateWorkspace.slug]);
|
||||||
|
|
||||||
const loadEvents = useCallback(async () => {
|
const loadEvents = useCallback(async ({ background = false } = {}) => {
|
||||||
setLoading(true);
|
if (background) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setInitialLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
|
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
|
||||||
apiClient.get(`/api/businesses/${businessId}/events`),
|
apiClient.get(`/api/businesses/${businessId}/events`),
|
||||||
|
|
@ -771,21 +755,27 @@ export default function Events() {
|
||||||
} = buildTemplateUiState(templates);
|
} = buildTemplateUiState(templates);
|
||||||
|
|
||||||
setEvents(eventsRes.data.events || []);
|
setEvents(eventsRes.data.events || []);
|
||||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl);
|
||||||
setVariants(nextVariants);
|
setVariants(nextVariants);
|
||||||
setGenState(nextGenState);
|
setGenState(nextGenState);
|
||||||
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||||||
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
|
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
|
||||||
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||||||
|
setError('');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load events');
|
setError('Failed to load events');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (background) {
|
||||||
|
setRefreshing(false);
|
||||||
|
} else {
|
||||||
|
hasLoadedEventsRef.current = true;
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [businessId]);
|
}, [businessId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEvents();
|
loadEvents({ background: hasLoadedEventsRef.current });
|
||||||
}, [loadEvents]);
|
}, [loadEvents]);
|
||||||
|
|
||||||
async function handleAddEvent(e) {
|
async function handleAddEvent(e) {
|
||||||
|
|
@ -797,7 +787,7 @@ export default function Events() {
|
||||||
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
|
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
|
||||||
setNewLabel('');
|
setNewLabel('');
|
||||||
setShowAddForm(false);
|
setShowAddForm(false);
|
||||||
await loadEvents();
|
await loadEvents({ background: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to add event');
|
setError(err.response?.data?.error || 'Failed to add event');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -808,7 +798,7 @@ export default function Events() {
|
||||||
async function handleDelete(slug) {
|
async function handleDelete(slug) {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
|
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
|
||||||
await loadEvents();
|
await loadEvents({ background: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to delete event');
|
setError(err.response?.data?.error || 'Failed to delete event');
|
||||||
}
|
}
|
||||||
|
|
@ -1071,6 +1061,7 @@ export default function Events() {
|
||||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||||
validationStatus: 'checking',
|
validationStatus: 'checking',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1081,12 +1072,24 @@ export default function Events() {
|
||||||
editedTemplate,
|
editedTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const issues = Array.isArray(res.data?.issues)
|
||||||
|
? res.data.issues
|
||||||
|
.filter((issue) => issue && typeof issue === 'object')
|
||||||
|
.map((issue) => ({
|
||||||
|
code: String(issue.code || '').trim(),
|
||||||
|
message: String(issue.message || '').trim(),
|
||||||
|
evidence: String(issue.evidence || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((issue) => issue.message)
|
||||||
|
: [];
|
||||||
|
|
||||||
setVariantDrafts((currentDrafts) => ({
|
setVariantDrafts((currentDrafts) => ({
|
||||||
...currentDrafts,
|
...currentDrafts,
|
||||||
[draftKey]: {
|
[draftKey]: {
|
||||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||||
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
||||||
why: res.data?.why || '',
|
why: String(res.data?.why || issues[0]?.message || ''),
|
||||||
|
issues,
|
||||||
lastCheckedText: editedTemplate,
|
lastCheckedText: editedTemplate,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1098,6 +1101,7 @@ export default function Events() {
|
||||||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||||||
validationStatus: 'idle',
|
validationStatus: 'idle',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1152,6 +1156,7 @@ export default function Events() {
|
||||||
currentText: nextText,
|
currentText: nextText,
|
||||||
validationStatus: 'idle',
|
validationStatus: 'idle',
|
||||||
why: '',
|
why: '',
|
||||||
|
issues: [],
|
||||||
lastCheckedText: '',
|
lastCheckedText: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -1216,14 +1221,7 @@ export default function Events() {
|
||||||
handleGenerate(slug, { sessionId });
|
handleGenerate(slug, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(groupId) {
|
if (initialLoading) {
|
||||||
setExpandedGroups((current) => ({
|
|
||||||
...current,
|
|
||||||
[groupId]: !current[groupId],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||||
|
|
@ -1231,8 +1229,8 @@ export default function Events() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedEvents = buildGroupedEvents(events, searchTerm);
|
const eventSections = buildVisibleEventSections(events, searchTerm);
|
||||||
const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.events.length, 0);
|
const totalVisibleEvents = eventSections.reduce((count, section) => count + section.events.length, 0);
|
||||||
const workspaceSlug = templateWorkspace.slug;
|
const workspaceSlug = templateWorkspace.slug;
|
||||||
const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null;
|
const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null;
|
||||||
const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : [];
|
const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : [];
|
||||||
|
|
@ -1259,8 +1257,16 @@ export default function Events() {
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
|
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
|
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
|
{refreshing && (
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
|
||||||
|
Updating events
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for customer-facing lifecycle events.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="relative flex-1 sm:max-w-md">
|
<div className="relative flex-1 sm:max-w-md">
|
||||||
|
|
@ -1336,50 +1342,27 @@ export default function Events() {
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupedEvents.length === 0 ? (
|
{eventSections.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center ">
|
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center ">
|
||||||
<p className="text-base font-semibold text-gray-800">No events match your search.</p>
|
<p className="text-base font-semibold text-gray-800">No events match your search.</p>
|
||||||
<p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the full lifecycle list.</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
{groupedEvents.map((group) => {
|
{eventSections.map((section) => (
|
||||||
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
|
<section key={section.id} className="space-y-3">
|
||||||
const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
|
<div className="px-1">
|
||||||
|
|
||||||
return (
|
|
||||||
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleGroup(group.id)}
|
|
||||||
className="group flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-start gap-4">
|
|
||||||
<div className={`mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${groupStyle.markerShell}`}>
|
|
||||||
<span className={`h-2.5 w-2.5 rounded-full ${groupStyle.markerDot}`} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-bold tracking-tight text-gray-800">{group.label}</h2>
|
<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">
|
<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
|
{section.events.length} events
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
|
<p className="mt-1 text-sm font-medium text-gray-500">{section.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 text-gray-600 shadow-sm transition group-hover:border-gray-300 group-hover:bg-white group-hover:text-gray-800 ${isExpanded ? 'rotate-180' : ''
|
|
||||||
}`}>
|
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-t border-gray-100 bg-white px-4 py-4 sm:px-6">
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{group.events.map((event) => {
|
{section.events.map((event) => {
|
||||||
const state = genState[event.slug] || 'idle';
|
const state = genState[event.slug] || 'idle';
|
||||||
const eventVariants = variants[event.slug] || [];
|
const eventVariants = variants[event.slug] || [];
|
||||||
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
||||||
|
|
@ -1416,9 +1399,8 @@ export default function Events() {
|
||||||
<span
|
<span
|
||||||
title={statusConfig.label}
|
title={statusConfig.label}
|
||||||
aria-label={statusConfig.label}
|
aria-label={statusConfig.label}
|
||||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
|
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
|
||||||
>
|
>
|
||||||
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
|
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1458,11 +1440,8 @@ export default function Events() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,31 +1,130 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import { useBusiness } from '../context/BusinessContext';
|
import { useBusiness } from '../context/BusinessContext';
|
||||||
|
|
||||||
const DESKTOP_SPLIT_QUERY = '(min-width: 1100px)';
|
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
|
||||||
const DEFAULT_LIST_PANE_WIDTH = 340;
|
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
|
||||||
const MIN_LIST_PANE_WIDTH = 280;
|
|
||||||
const MAX_LIST_PANE_WIDTH = 420;
|
|
||||||
const MIN_DETAIL_PANE_WIDTH = 440;
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
function isPendingSenderIdProfile(profile) {
|
||||||
return Math.min(Math.max(value, min), max);
|
const normalizedName = String(profile?.name || '').trim();
|
||||||
|
const senderId = String(profile?.provider?.senderId || '').trim();
|
||||||
|
return (profile?.isAutoNamed === true && !senderId) || normalizedName === PENDING_SENDER_ID_PROFILE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMissingProviderFields(profile) {
|
function normalizeCurlForDisplay(value) {
|
||||||
const provider = profile?.provider || {};
|
if (!value) return '';
|
||||||
const missing = [];
|
|
||||||
|
|
||||||
if (!provider.providerName) missing.push('Provider Name');
|
return String(value)
|
||||||
if (!provider.senderId) missing.push('Sender ID');
|
.trim()
|
||||||
if (!provider.dltEntityId) missing.push('DLT Entity ID');
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\\r\\n/g, '\n')
|
||||||
return missing;
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\t/g, ' ')
|
||||||
|
.replace(/\\'/g, '\'')
|
||||||
|
.replace(/\\"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProviderSetupComplete(profile) {
|
function stripWrappingQuotes(value) {
|
||||||
return getMissingProviderFields(profile).length === 0;
|
if (!value || value.length < 2) return value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.startsWith('\'') && value.endsWith('\''))
|
||||||
|
|| (value.startsWith('"') && value.endsWith('"'))
|
||||||
|
) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurlCommand(normalizedCurl) {
|
||||||
|
if (!normalizedCurl) return '';
|
||||||
|
|
||||||
|
let output = normalizedCurl;
|
||||||
|
|
||||||
|
if (!output.includes('\n')) {
|
||||||
|
output = output
|
||||||
|
.replace(/^curl\s+/, 'curl\n ')
|
||||||
|
.replace(/\s+(--request|-X|--url|--header|-H|--data-raw|--data|-d|--compressed|--location|--insecure|--fail)\b/g, '\n $1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCurlBody(normalizedCurl) {
|
||||||
|
if (!normalizedCurl) return '';
|
||||||
|
|
||||||
|
const quotedMatch = normalizedCurl.match(
|
||||||
|
/(?:--data-raw|--data|-d)\s+(["'])([\s\S]*?)\1(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
|
||||||
|
);
|
||||||
|
if (quotedMatch?.[2]) return stripWrappingQuotes(quotedMatch[2].trim());
|
||||||
|
|
||||||
|
const braceMatch = normalizedCurl.match(
|
||||||
|
/(?:--data-raw|--data|-d)\s+({[\s\S]*})(?=\s+(?:--[a-z-]+|-\w)\b|$)/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
return braceMatch?.[1]?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurlViewModel(value) {
|
||||||
|
const normalizedCurl = normalizeCurlForDisplay(value);
|
||||||
|
const headers = [
|
||||||
|
...normalizedCurl.matchAll(/(?:--header|-H)\s+(?:"([^"]+)"|'([^']+)')/g),
|
||||||
|
]
|
||||||
|
.map((match) => (match[1] || match[2] || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const methodMatch = normalizedCurl.match(/(?:--request|-X)\s+([A-Z]+)/i);
|
||||||
|
const method = (methodMatch?.[1] || (/(?:--data-raw|--data|-d)\b/i.test(normalizedCurl) ? 'POST' : 'GET')).toUpperCase();
|
||||||
|
const url = normalizedCurl.match(/https?:\/\/[^\s'"]+/i)?.[0] || '';
|
||||||
|
const rawBody = extractCurlBody(normalizedCurl);
|
||||||
|
|
||||||
|
let payload = stripWrappingQuotes(rawBody || '').trim();
|
||||||
|
let prettyPayload = '';
|
||||||
|
let payloadFormat = '';
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
prettyPayload = JSON.stringify(parsed, null, 2);
|
||||||
|
payloadFormat = 'json';
|
||||||
|
} catch {
|
||||||
|
prettyPayload = payload;
|
||||||
|
payloadFormat = 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = '';
|
||||||
|
try {
|
||||||
|
host = url ? new URL(url).host : '';
|
||||||
|
} catch {
|
||||||
|
host = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellLines = [];
|
||||||
|
if (url) {
|
||||||
|
shellLines.push('curl \\');
|
||||||
|
shellLines.push(` --request ${method} \\`);
|
||||||
|
shellLines.push(` --url '${url}'${headers.length || rawBody ? ' \\' : ''}`);
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const hasTrailingSection = index < headers.length - 1 || Boolean(rawBody);
|
||||||
|
shellLines.push(` --header '${header}'${hasTrailingSection ? ' \\' : ''}`);
|
||||||
|
});
|
||||||
|
if (rawBody) {
|
||||||
|
shellLines.push(` --data-raw '${payloadFormat === 'json' ? '<payload shown below>' : rawBody}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: shellLines.length > 0 ? shellLines.join('\n') : formatCurlCommand(normalizedCurl),
|
||||||
|
headers,
|
||||||
|
host,
|
||||||
|
method,
|
||||||
|
payload: prettyPayload,
|
||||||
|
payloadFormat,
|
||||||
|
url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUpdatedAt(value) {
|
function formatUpdatedAt(value) {
|
||||||
|
|
@ -38,58 +137,97 @@ function formatUpdatedAt(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProviderSummary(profile) {
|
function buildProfilePatchPayload(inputs = [], values = {}) {
|
||||||
const provider = profile?.provider || {};
|
const provider = {};
|
||||||
|
const profileInputValues = {};
|
||||||
|
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const rawValue = String(values[input.key] ?? '').trim();
|
||||||
|
if (!rawValue) return;
|
||||||
|
|
||||||
|
if (BASE_PROFILE_KEYS.has(input.key)) {
|
||||||
|
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
profileInputValues[input.key] = rawValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(Object.keys(provider).length > 0 ? { provider } : {}),
|
||||||
|
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialFormValues(inputs = []) {
|
||||||
|
return inputs.reduce((accumulator, input) => {
|
||||||
|
accumulator[input.key] = input.secret ? '' : (input.value || '');
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileSummary(profile) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const provider = profile?.provider || {};
|
||||||
|
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
|
||||||
|
|
||||||
if (provider.providerName) parts.push(provider.providerName);
|
if (provider.providerName) parts.push(provider.providerName);
|
||||||
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
|
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
|
||||||
if (provider.dltEntityId) parts.push('DLT added');
|
if (provider.dltEntityId) parts.push('DLT ready');
|
||||||
|
if (missingCount > 0) parts.push(`${missingCount} pending`);
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(' • ') : 'Provider details not completed yet';
|
return parts.join(' • ') || 'Profile saved. Complete the required fields to use it everywhere.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileStatusPill({ complete }) {
|
function ProfileStatusPill({ complete }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${complete
|
className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${
|
||||||
|
complete
|
||||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||||
: 'border-amber-200 bg-amber-50 text-amber-700'
|
: 'border-amber-200 bg-amber-50 text-amber-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{complete ? 'Complete' : 'Missing Fields'}
|
{complete ? 'Ready' : 'Needs Fields'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InspectorRow({ label, value, valueClassName = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">{label}</p>
|
||||||
|
<p className={`mt-2 text-sm font-medium text-gray-900 ${valueClassName}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Providers() {
|
export default function Providers() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refreshOnboardingState } = useBusiness();
|
const { refreshOnboardingState } = useBusiness();
|
||||||
const [loading, setLoading] = useState(true);
|
const hasLoadedProfilesRef = useRef(false);
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [profiles, setProfiles] = useState([]);
|
const [profiles, setProfiles] = useState([]);
|
||||||
const [activeProfileId, setActiveProfileId] = useState('');
|
const [activeProfileId, setActiveProfileId] = useState('');
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState('');
|
const [selectedProfileId, setSelectedProfileId] = useState('');
|
||||||
const [form, setForm] = useState({
|
const [formValues, setFormValues] = useState({});
|
||||||
providerName: '',
|
const [revealedProfiles, setRevealedProfiles] = useState({});
|
||||||
senderId: '',
|
const [showSecretsByProfileId, setShowSecretsByProfileId] = useState({});
|
||||||
dltEntityId: '',
|
|
||||||
authKey: '',
|
|
||||||
});
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [copiedProfileId, setCopiedProfileId] = useState('');
|
|
||||||
const [isDesktopSplit, setIsDesktopSplit] = useState(false);
|
|
||||||
const [listPaneWidth, setListPaneWidth] = useState(DEFAULT_LIST_PANE_WIDTH);
|
|
||||||
const layoutRef = useRef(null);
|
|
||||||
const copyTimeoutRef = useRef(null);
|
|
||||||
|
|
||||||
const globalSmsPath = `/${businessId}/global-sms`;
|
const globalSmsPath = `/${businessId}/global-sms`;
|
||||||
|
|
||||||
const loadProfiles = useCallback(async () => {
|
const loadProfiles = useCallback(async ({ background = false } = {}) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
if (background) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setInitialLoading(true);
|
||||||
|
}
|
||||||
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
|
||||||
const fetchedProfiles = res.data?.profiles || [];
|
const fetchedProfiles = res.data?.profiles || [];
|
||||||
const nextActiveProfileId = String(res.data?.activeProfileId || '');
|
const nextActiveProfileId = String(res.data?.activeProfileId || '');
|
||||||
|
|
@ -101,63 +239,58 @@ export default function Providers() {
|
||||||
? currentSelectedProfileId
|
? currentSelectedProfileId
|
||||||
: ''
|
: ''
|
||||||
));
|
));
|
||||||
|
setError('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to load provider profiles');
|
setError(err.response?.data?.error || 'Failed to load provider profiles');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (background) {
|
||||||
|
setRefreshing(false);
|
||||||
|
} else {
|
||||||
|
hasLoadedProfilesRef.current = true;
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [businessId]);
|
}, [businessId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProfiles();
|
loadProfiles({ background: hasLoadedProfilesRef.current });
|
||||||
}, [loadProfiles]);
|
}, [loadProfiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedProfile = useMemo(
|
||||||
const mediaQuery = window.matchMedia(DESKTOP_SPLIT_QUERY);
|
() => profiles.find((profile) => profile.id === selectedProfileId) || null,
|
||||||
const syncLayoutMode = (event) => setIsDesktopSplit(event.matches);
|
[profiles, selectedProfileId],
|
||||||
|
);
|
||||||
setIsDesktopSplit(mediaQuery.matches);
|
const selectedProfileInputs = selectedProfile?.profileInputs || [];
|
||||||
|
const isSelectedProfileRevealed = selectedProfile ? showSecretsByProfileId[selectedProfile.id] === true : false;
|
||||||
if (typeof mediaQuery.addEventListener === 'function') {
|
const selectedRevealData = selectedProfile ? revealedProfiles[selectedProfile.id] : null;
|
||||||
mediaQuery.addEventListener('change', syncLayoutMode);
|
const selectedDisplayCurl = selectedProfile
|
||||||
return () => mediaQuery.removeEventListener('change', syncLayoutMode);
|
? (isSelectedProfileRevealed
|
||||||
}
|
? (selectedRevealData?.rawCurl || selectedProfile.maskedCurl)
|
||||||
|
: selectedProfile.maskedCurl)
|
||||||
mediaQuery.addListener(syncLayoutMode);
|
: '';
|
||||||
return () => mediaQuery.removeListener(syncLayoutMode);
|
const selectedCurlView = useMemo(
|
||||||
}, []);
|
() => buildCurlViewModel(selectedDisplayCurl),
|
||||||
|
[selectedDisplayCurl],
|
||||||
useEffect(() => () => {
|
);
|
||||||
if (copyTimeoutRef.current) {
|
const missingInputCount = selectedProfile?.executionReadiness?.missingProfileInputs?.length || 0;
|
||||||
clearTimeout(copyTimeoutRef.current);
|
const curlWarnings = selectedProfile?.curlAnalysis?.warnings || [];
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) || null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProfile) {
|
if (!selectedProfile) {
|
||||||
setForm({
|
setFormValues({});
|
||||||
providerName: '',
|
|
||||||
senderId: '',
|
|
||||||
dltEntityId: '',
|
|
||||||
authKey: '',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = selectedProfile.provider || {};
|
setFormValues(getInitialFormValues(selectedProfile.profileInputs));
|
||||||
setForm({
|
|
||||||
providerName: provider.providerName || '',
|
|
||||||
senderId: provider.senderId || '',
|
|
||||||
dltEntityId: provider.dltEntityId || '',
|
|
||||||
authKey: provider.authKey || '',
|
|
||||||
});
|
|
||||||
}, [selectedProfile]);
|
}, [selectedProfile]);
|
||||||
|
|
||||||
function handleChange(field, value) {
|
const ensureRevealData = useCallback(async (profileId) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
|
||||||
}
|
|
||||||
|
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
|
||||||
|
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
|
||||||
|
return res.data;
|
||||||
|
}, [businessId, revealedProfiles]);
|
||||||
|
|
||||||
function handleSelectProfile(profileId) {
|
function handleSelectProfile(profileId) {
|
||||||
setSelectedProfileId(profileId);
|
setSelectedProfileId(profileId);
|
||||||
|
|
@ -165,37 +298,10 @@ export default function Providers() {
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResizeStart(event) {
|
function handleReturnToList() {
|
||||||
if (!isDesktopSplit) return;
|
setSelectedProfileId('');
|
||||||
|
setError('');
|
||||||
event.preventDefault();
|
setSuccess('');
|
||||||
const containerBounds = layoutRef.current?.getBoundingClientRect();
|
|
||||||
if (!containerBounds) return;
|
|
||||||
|
|
||||||
const maxAllowedWidth = clamp(
|
|
||||||
containerBounds.width - MIN_DETAIL_PANE_WIDTH,
|
|
||||||
MIN_LIST_PANE_WIDTH,
|
|
||||||
MAX_LIST_PANE_WIDTH,
|
|
||||||
);
|
|
||||||
|
|
||||||
function handlePointerMove(moveEvent) {
|
|
||||||
const nextWidth = clamp(
|
|
||||||
moveEvent.clientX - containerBounds.left,
|
|
||||||
MIN_LIST_PANE_WIDTH,
|
|
||||||
maxAllowedWidth,
|
|
||||||
);
|
|
||||||
setListPaneWidth(nextWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerUp() {
|
|
||||||
document.body.style.userSelect = '';
|
|
||||||
window.removeEventListener('mousemove', handlePointerMove);
|
|
||||||
window.removeEventListener('mouseup', handlePointerUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.style.userSelect = 'none';
|
|
||||||
window.addEventListener('mousemove', handlePointerMove);
|
|
||||||
window.addEventListener('mouseup', handlePointerUp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleActivate(profile) {
|
async function handleActivate(profile) {
|
||||||
|
|
@ -206,7 +312,7 @@ export default function Providers() {
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
|
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/activate`);
|
||||||
setSelectedProfileId(profile.id);
|
setSelectedProfileId(profile.id);
|
||||||
await loadProfiles();
|
await loadProfiles({ background: true });
|
||||||
await refreshOnboardingState(businessId).catch(() => null);
|
await refreshOnboardingState(businessId).catch(() => null);
|
||||||
setSuccess(`${profile.name} is now the active profile.`);
|
setSuccess(`${profile.name} is now the active profile.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -214,55 +320,57 @@ export default function Providers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleReveal(profile) {
|
||||||
|
if (!profile?.id) return;
|
||||||
|
|
||||||
|
const shouldReveal = !showSecretsByProfileId[profile.id];
|
||||||
|
if (shouldReveal) {
|
||||||
|
try {
|
||||||
|
const revealData = await ensureRevealData(profile.id);
|
||||||
|
const revealedValues = (revealData?.profileInputs || []).reduce((accumulator, input) => {
|
||||||
|
accumulator[input.key] = input.value || '';
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
setFormValues((current) => ({ ...current, ...revealedValues }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to reveal saved values');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowSecretsByProfileId((current) => ({
|
||||||
|
...current,
|
||||||
|
[profile.id]: shouldReveal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCopyCurl(profile) {
|
async function handleCopyCurl(profile) {
|
||||||
if (!profile?.rawCurl) return;
|
if (!profile?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!navigator?.clipboard?.writeText) {
|
const revealData = await ensureRevealData(profile.id);
|
||||||
throw new Error('Clipboard API unavailable');
|
if (!revealData?.rawCurl) return;
|
||||||
}
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(profile.rawCurl);
|
await navigator.clipboard.writeText(revealData.rawCurl);
|
||||||
setCopiedProfileId(profile.id);
|
setSuccess(`Copied ${profile.name} cURL.`);
|
||||||
|
} catch (err) {
|
||||||
if (copyTimeoutRef.current) {
|
setError(err.response?.data?.error || 'Failed to copy the cURL command.');
|
||||||
clearTimeout(copyTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
copyTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setCopiedProfileId('');
|
|
||||||
}, 1800);
|
|
||||||
} catch {
|
|
||||||
setError('Failed to copy the cURL command.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(event) {
|
async function handleSave(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!selectedProfile?.id) return;
|
if (!selectedProfile?.id) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
|
|
||||||
if (form.senderId && !/^[A-Za-z]{6}$/.test(form.senderId)) {
|
|
||||||
setError('DLT Sender ID must be exactly 6 alphabet characters');
|
|
||||||
setSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, {
|
const payload = buildProfilePatchPayload(selectedProfileInputs, formValues);
|
||||||
provider: {
|
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${selectedProfile.id}`, payload);
|
||||||
providerName: form.providerName,
|
|
||||||
senderId: form.senderId.toUpperCase(),
|
|
||||||
dltEntityId: form.dltEntityId,
|
|
||||||
authKey: form.authKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadProfiles();
|
await loadProfiles({ background: true });
|
||||||
await refreshOnboardingState(businessId).catch(() => null);
|
await refreshOnboardingState(businessId).catch(() => null);
|
||||||
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
|
setSuccess(`Provider configuration saved for ${selectedProfile.name}.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -272,21 +380,29 @@ export default function Providers() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (initialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6 pb-12">
|
<div className="mx-auto max-w-6xl space-y-6 pb-12">
|
||||||
<div className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 border-b border-gray-200 pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Provider Configuration</h1>
|
||||||
|
{refreshing && (
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
|
||||||
|
Refreshing profiles
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="mt-1 text-sm font-medium text-gray-500">
|
<p className="mt-1 text-sm font-medium text-gray-500">
|
||||||
Pick a saved profile to review its complete request and manage the provider details stored against it.
|
Review each saved cURL profile and update the required provider or credential fields without changing the stored cURL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -299,23 +415,24 @@ export default function Providers() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
||||||
{error}
|
{error}
|
||||||
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
<button type="button" onClick={() => setError('')} className="font-bold text-gray-600 hover:text-gray-700">
|
||||||
</div>
|
×
|
||||||
)}
|
</button>
|
||||||
{success && (
|
|
||||||
<div className="px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
|
|
||||||
{success}
|
|
||||||
<button onClick={() => setSuccess('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{success && (
|
||||||
ref={layoutRef}
|
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
|
||||||
className={`grid gap-4 ${isDesktopSplit ? 'items-start' : ''}`}
|
{success}
|
||||||
style={isDesktopSplit ? { gridTemplateColumns: `${listPaneWidth}px 12px minmax(0, 1fr)` } : undefined}
|
<button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-600 hover:text-gray-700">
|
||||||
>
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedProfile ? (
|
||||||
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
<div className="border-b border-gray-200 px-5 py-4">
|
<div className="border-b border-gray-200 px-5 py-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|
@ -343,27 +460,23 @@ export default function Providers() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[70vh] overflow-y-auto overscroll-contain p-3">
|
<div className="p-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{profiles.map((profile) => {
|
{profiles.map((profile) => {
|
||||||
const isActive = profile.id === activeProfileId;
|
const isActive = profile.id === activeProfileId;
|
||||||
const isSelected = profile.id === selectedProfileId;
|
const complete = profile.executionReadiness?.isSetupComplete === true;
|
||||||
const complete = isProviderSetupComplete(profile);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={profile.id}
|
key={profile.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectProfile(profile.id)}
|
onClick={() => handleSelectProfile(profile.id)}
|
||||||
className={`w-full rounded-xl border p-4 text-left transition ${isSelected
|
className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left transition hover:border-primary-blue hover:bg-gray-50"
|
||||||
? 'border-primary-blue bg-indigo-50/40 shadow-sm'
|
|
||||||
: 'border-gray-200 bg-white hover:border-primary-blue hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold text-gray-900">{profile.name}</p>
|
<p className={`truncate text-sm font-semibold ${isPendingSenderIdProfile(profile) ? 'text-error-text' : 'text-gray-900'}`}>{profile.name}</p>
|
||||||
<p className="mt-1 text-sm text-gray-500">{buildProviderSummary(profile)}</p>
|
<p className="mt-1 text-sm leading-relaxed text-gray-500">{getProfileSummary(profile)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
|
|
@ -387,34 +500,20 @@ export default function Providers() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
) : (
|
||||||
{isDesktopSplit && (
|
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
<div className="hidden self-stretch lg:flex items-stretch justify-center">
|
<div className="border-b border-gray-200 px-6 py-5">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={handleResizeStart}
|
onClick={handleReturnToList}
|
||||||
className="group relative flex h-full w-3 cursor-col-resize items-stretch justify-center bg-transparent"
|
className="inline-flex w-fit items-center gap-2 text-sm font-semibold text-gray-500 transition hover:text-primary-blue"
|
||||||
aria-label="Resize provider profile list"
|
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none absolute inset-y-2 left-1/2 w-[0.5px] -translate-x-1/2 rounded-full bg-gray-300 transition group-hover:bg-primary-blue" />
|
<span>Saved Profiles</span>
|
||||||
|
<span className="text-gray-300">/</span>
|
||||||
|
<span className="text-gray-900">{selectedProfile.name}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section className="min-w-0 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
|
|
||||||
{!selectedProfile ? (
|
|
||||||
<div className="flex min-h-[520px] items-center justify-center px-6 py-10 text-center">
|
|
||||||
<div className="max-w-md">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-400">Select a profile</p>
|
|
||||||
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-gray-900">Choose a saved profile to review</h2>
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-gray-500">
|
|
||||||
The selected profile will open here with a complete cURL preview, provider details, and activation controls.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="border-b border-gray-200 px-6 py-5">
|
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -424,10 +523,10 @@ export default function Providers() {
|
||||||
Active profile
|
Active profile
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ProfileStatusPill complete={isProviderSetupComplete(selectedProfile)} />
|
<ProfileStatusPill complete={selectedProfile.executionReadiness?.isSetupComplete === true} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
Review the exact saved request, then update the provider fields tied to this profile.
|
The stored cURL is immutable after validation. You can review it, reveal it, and update the profile fields it depends on.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -443,147 +542,192 @@ export default function Providers() {
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCopyCurl(selectedProfile)}
|
onClick={() => handleToggleReveal(selectedProfile)}
|
||||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
||||||
>
|
>
|
||||||
{copiedProfileId === selectedProfile.id ? 'Copied' : 'Copy cURL'}
|
{showSecretsByProfileId[selectedProfile.id] ? 'Hide Values' : 'Reveal Values'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`${globalSmsPath}?editProfile=${encodeURIComponent(selectedProfile.id)}`)}
|
onClick={() => handleCopyCurl(selectedProfile)}
|
||||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-primary-blue hover:bg-gray-50 hover:text-primary-blue"
|
||||||
>
|
>
|
||||||
Edit cURL
|
Copy cURL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 px-6 py-6">
|
<div className="space-y-6 px-6 py-6">
|
||||||
<div className="rounded-2xl border border-gray-200 bg-gray-950 overflow-hidden">
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px] xl:items-start">
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-gray-800 px-4 py-3">
|
<div className="overflow-hidden rounded-[28px] border border-slate-200 bg-slate-950 shadow-[0_28px_60px_-42px_rgba(15,23,42,0.75)]">
|
||||||
<div>
|
<div className="border-b border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.2),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.98),_rgba(2,6,23,0.96))] px-5 py-5">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Preview</p>
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
|
||||||
|
{selectedCurlView.method}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-white">
|
||||||
|
{selectedCurlView.url || 'Endpoint not detected from stored cURL'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs font-medium text-slate-400">
|
||||||
|
{isSelectedProfileRevealed
|
||||||
|
? 'Saved values are currently rendered inside this request preview.'
|
||||||
|
: 'Sensitive values stay masked until you explicitly reveal them.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-gray-700 bg-gray-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-300">
|
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">
|
||||||
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
Updated {formatUpdatedAt(selectedProfile.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-72 overflow-y-auto overscroll-contain whitespace-pre-wrap break-all px-4 py-4 text-xs leading-relaxed text-gray-100">
|
|
||||||
<code>{selectedProfile.rawCurl}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_260px]">
|
|
||||||
<form onSubmit={handleSave} className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
|
|
||||||
<div className="border-b border-gray-200 px-5 py-4">
|
|
||||||
<p className="text-sm font-semibold text-gray-900">Provider Details</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
These fields are stored against this profile and are used during template publishing.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-5 px-5 py-5">
|
<div className="space-y-5 px-5 py-5">
|
||||||
<div>
|
<div>
|
||||||
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.providerName ? 'text-error-text' : 'text-text-primary'}`}>
|
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Shell View</p>
|
||||||
Provider Name {!form.providerName && <span className="text-error-text">*</span>}
|
<pre className="max-h-[26rem] overflow-y-auto overscroll-contain rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-[13px] leading-7 text-slate-100 shadow-inner">
|
||||||
</label>
|
<code>{selectedCurlView.command || 'No cURL stored.'}</code>
|
||||||
<input
|
</pre>
|
||||||
type="text"
|
|
||||||
value={form.providerName}
|
|
||||||
onChange={(event) => handleChange('providerName', event.target.value)}
|
|
||||||
className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.providerName ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary placeholder-placeholder-bg font-medium focus:outline-none focus:ring-2 focus:border-transparent transition text-sm`}
|
|
||||||
placeholder="e.g. MSG91, Gupshup"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
{selectedCurlView.payload && (
|
||||||
<div>
|
<div>
|
||||||
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.senderId ? 'text-error-text' : 'text-text-primary'}`}>
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
DLT Sender ID {!form.senderId && <span className="text-error-text">*</span>}
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Request Payload</p>
|
||||||
</label>
|
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">
|
||||||
<input
|
{selectedCurlView.payloadFormat === 'json' ? 'JSON' : 'Text'}
|
||||||
type="text"
|
</span>
|
||||||
value={form.senderId}
|
|
||||||
onChange={(event) => handleChange('senderId', event.target.value.toUpperCase())}
|
|
||||||
maxLength={6}
|
|
||||||
className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.senderId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono tracking-widest placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm uppercase`}
|
|
||||||
placeholder="6 CHARS"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs font-medium text-gray-500">Exactly 6 alphabetic characters.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<pre className="max-h-[22rem] overflow-y-auto overscroll-contain rounded-2xl border border-emerald-400/10 bg-emerald-400/5 px-4 py-4 text-[13px] leading-7 text-emerald-50 shadow-inner">
|
||||||
<div>
|
<code>{selectedCurlView.payload}</code>
|
||||||
<label className={`block text-sm font-semibold mb-1.5 tracking-wide ${!form.dltEntityId ? 'text-error-text' : 'text-text-primary'}`}>
|
</pre>
|
||||||
DLT Entity ID {!form.dltEntityId && <span className="text-error-text">*</span>}
|
</div>
|
||||||
</label>
|
)}
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.dltEntityId}
|
|
||||||
onChange={(event) => handleChange('dltEntityId', event.target.value)}
|
|
||||||
className={`w-full px-4 py-2 rounded-lg bg-surface-white border ${!form.dltEntityId ? 'border-error-text focus:ring-error-text' : 'border-border-main focus:ring-primary-blue'} text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:border-transparent transition text-sm`}
|
|
||||||
placeholder="19-digit DLT PE ID"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="rounded-[28px] border border-gray-200 bg-gray-50 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-text-primary mb-1.5 tracking-wide">
|
<p className="text-sm font-semibold text-gray-900">Profile Inspector</p>
|
||||||
API Auth Key <span className="text-text-muted font-normal text-xs">(Optional)</span>
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Keep the request front and center, then reveal stored values only when you need to inspect or edit them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${isSelectedProfileRevealed ? 'border-indigo-200 bg-indigo-50 text-primary-dark' : 'border-gray-200 bg-white text-gray-500'}`}>
|
||||||
|
{isSelectedProfileRevealed ? 'Values visible' : 'Values hidden'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<InspectorRow
|
||||||
|
label="Profile State"
|
||||||
|
value={selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Provider"
|
||||||
|
value={selectedProfile.provider?.providerName || selectedProfile.curlAnalysis?.providerName || 'Awaiting provider name'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Endpoint Host"
|
||||||
|
value={selectedCurlView.host || 'Not detected'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Auth Mode"
|
||||||
|
value={selectedProfile.curlAnalysis?.authMode || 'Not detected'}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Profile Fields"
|
||||||
|
value={`${selectedProfileInputs.length} stored value${selectedProfileInputs.length === 1 ? '' : 's'}`}
|
||||||
|
/>
|
||||||
|
<InspectorRow
|
||||||
|
label="Setup"
|
||||||
|
value={selectedProfile.executionReadiness?.isSetupComplete
|
||||||
|
? 'All required profile inputs are complete.'
|
||||||
|
: `${missingInputCount} required field${missingInputCount === 1 ? '' : 's'} still missing`}
|
||||||
|
valueClassName={selectedProfile.executionReadiness?.isSetupComplete ? '' : 'text-amber-700'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{curlWarnings.length > 0 && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">Warnings</p>
|
||||||
|
<ul className="mt-2 space-y-2 text-sm text-amber-900">
|
||||||
|
{curlWarnings.map((warning) => (
|
||||||
|
<li key={warning}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelectedProfileRevealed ? (
|
||||||
|
<form onSubmit={handleSave} className="overflow-hidden rounded-[28px] border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-gray-200 px-5 py-4">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Stored Profile Values</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
These fields appear only in reveal mode and stay tied to this immutable cURL profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
{selectedProfileInputs.length > 0 ? selectedProfileInputs.map((input) => (
|
||||||
|
<div key={input.key} className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4">
|
||||||
|
<label className={`mb-1.5 block text-sm font-semibold ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'text-error-text' : 'text-gray-900'}`}>
|
||||||
|
{input.label} {input.required ? <span className="text-error-text">*</span> : null}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
value={form.authKey}
|
value={formValues[input.key] || ''}
|
||||||
onChange={(event) => handleChange('authKey', event.target.value)}
|
onChange={(event) => setFormValues((current) => ({
|
||||||
className="w-full px-4 py-2 rounded-lg bg-surface-white border border-border-main text-text-primary font-mono placeholder-placeholder-bg focus:outline-none focus:ring-2 focus:ring-primary-blue focus:border-transparent transition text-sm"
|
...current,
|
||||||
placeholder="Authorization key for your SMS provider"
|
[input.key]: input.key === 'senderId'
|
||||||
|
? event.target.value.toUpperCase()
|
||||||
|
: event.target.value,
|
||||||
|
}))}
|
||||||
|
className={`w-full rounded-xl border bg-white px-4 py-2.5 text-sm font-medium text-gray-900 transition focus:border-transparent focus:outline-none focus:ring-2 ${input.required && !input.hasValue && !String(formValues[input.key] || '').trim() ? 'border-error-text focus:ring-error-text' : 'border-gray-200 focus:ring-primary-blue'}`}
|
||||||
|
placeholder={input.label}
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs font-medium text-gray-500">Used as the Authorization header in your SMS requests.</p>
|
<p className="mt-2 text-xs font-medium text-gray-500">
|
||||||
|
{input.secret
|
||||||
|
? 'Sensitive value revealed for this inspection session.'
|
||||||
|
: input.source === 'embedded'
|
||||||
|
? 'Extracted from the accepted cURL and stored against this profile.'
|
||||||
|
: 'Stored on the profile before publish and runtime sends continue.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-4 text-sm text-gray-600">
|
||||||
|
No profile-level stored values were extracted from this cURL.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
|
<div className="flex justify-end border-t border-gray-200 bg-white px-5 py-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white font-semibold text-sm transition disabled:opacity-50 flex items-center justify-center gap-2"
|
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving…</> : 'Save Configuration'}
|
{saving ? 'Saving…' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
<aside className="rounded-2xl border border-gray-200 bg-gray-50 p-5">
|
<div className="rounded-[28px] border border-dashed border-gray-300 bg-white px-5 py-5">
|
||||||
<p className="text-sm font-semibold text-gray-900">Current Status</p>
|
<p className="text-sm font-semibold text-gray-900">Values stay hidden by default</p>
|
||||||
<ul className="mt-4 space-y-3 text-sm">
|
<p className="mt-2 text-sm leading-relaxed text-gray-500">
|
||||||
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
|
Reveal mode will render stored values inside the cURL on the left and open the editable field inspector here.
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Profile State</p>
|
|
||||||
<p className="mt-2 font-medium text-gray-900">
|
|
||||||
{selectedProfile.id === activeProfileId ? 'Currently active for generation' : 'Inactive profile'}
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</div>
|
||||||
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
|
)}
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Provider Setup</p>
|
|
||||||
<p className="mt-2 font-medium text-gray-900">
|
|
||||||
{isProviderSetupComplete(selectedProfile)
|
|
||||||
? 'All mandatory provider fields are complete.'
|
|
||||||
: getMissingProviderFields(selectedProfile).join(', ')}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li className="rounded-xl border border-gray-200 bg-white px-4 py-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-400">Auth Key</p>
|
|
||||||
<p className="mt-2 font-medium text-gray-900">
|
|
||||||
{selectedProfile.provider?.authKey ? 'Saved on this profile' : 'Not added'}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ function getTemplateSortRank(template) {
|
||||||
export default function Templates() {
|
export default function Templates() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const hasLoadedTemplatesRef = useRef(false);
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
const [profilesById, setProfilesById] = useState({});
|
const [profilesById, setProfilesById] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
||||||
const [testTarget, setTestTarget] = useState(null);
|
const [testTarget, setTestTarget] = useState(null);
|
||||||
|
|
@ -96,9 +98,13 @@ export default function Templates() {
|
||||||
const highlightTimeoutRef = useRef(null);
|
const highlightTimeoutRef = useRef(null);
|
||||||
const handledFocusSlugRef = useRef('');
|
const handledFocusSlugRef = useRef('');
|
||||||
|
|
||||||
const loadTemplates = useCallback(async () => {
|
const loadTemplates = useCallback(async ({ background = false } = {}) => {
|
||||||
setLoading(true);
|
if (background) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setInitialLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [templatesRes, profilesRes] = await Promise.all([
|
const [templatesRes, profilesRes] = await Promise.all([
|
||||||
|
|
@ -111,15 +117,21 @@ export default function Templates() {
|
||||||
|
|
||||||
setTemplates(allTemplates);
|
setTemplates(allTemplates);
|
||||||
setProfilesById(profileMap);
|
setProfilesById(profileMap);
|
||||||
|
setError('');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load templates');
|
setError('Failed to load templates');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (background) {
|
||||||
|
setRefreshing(false);
|
||||||
|
} else {
|
||||||
|
hasLoadedTemplatesRef.current = true;
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [businessId]);
|
}, [businessId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemplates();
|
loadTemplates({ background: hasLoadedTemplatesRef.current });
|
||||||
}, [loadTemplates]);
|
}, [loadTemplates]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
|
|
@ -179,7 +191,7 @@ export default function Templates() {
|
||||||
|
|
||||||
async function handleWhitelistSuccess() {
|
async function handleWhitelistSuccess() {
|
||||||
setWhitelistTarget(null);
|
setWhitelistTarget(null);
|
||||||
await loadTemplates();
|
await loadTemplates({ background: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRuntimeToggle(template) {
|
async function handleRuntimeToggle(template) {
|
||||||
|
|
@ -207,7 +219,7 @@ export default function Templates() {
|
||||||
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
|
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (loading) {
|
if (initialLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" />
|
<div className="h-8 w-8 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" />
|
||||||
|
|
@ -218,7 +230,15 @@ export default function Templates() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl">
|
<div className="mx-auto max-w-6xl">
|
||||||
<div className="mb-6 border-b border-gray-200 pb-5">
|
<div className="mb-6 border-b border-gray-200 pb-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
|
||||||
|
{refreshing && (
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
|
||||||
|
Refreshing templates
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="mt-1 text-sm font-medium text-gray-500">
|
<p className="mt-1 text-sm font-medium text-gray-500">
|
||||||
Manage template runtime, whitelisting, and testing from one place.
|
Manage template runtime, whitelisting, and testing from one place.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -278,7 +298,7 @@ export default function Templates() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{visibleTemplates.map((template) => {
|
{visibleTemplates.map((template) => {
|
||||||
const appearance = getCardAppearance(template);
|
const appearance = getCardAppearance(template);
|
||||||
const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null;
|
const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null;
|
||||||
|
|
@ -297,26 +317,26 @@ export default function Templates() {
|
||||||
delete templateCardRefs.current[template.eventSlug];
|
delete templateCardRefs.current[template.eventSlug];
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
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} ${
|
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} ${
|
||||||
highlightedEventSlug === template.eventSlug
|
highlightedEventSlug === template.eventSlug
|
||||||
? 'ring-2 ring-primary-blue/30'
|
? 'ring-2 ring-primary-blue/30'
|
||||||
: 'hover:-translate-y-0.5 hover:shadow-[0_16px_34px_rgba(15,23,42,0.08)]'
|
: 'hover:-translate-y-0.5 hover:shadow-[0_12px_24px_rgba(15,23,42,0.07)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="text-[1.35rem] font-semibold tracking-tight text-gray-900">
|
<h3 className="text-base font-semibold tracking-tight text-gray-900">
|
||||||
{getTemplateDisplayName(template)}
|
{getTemplateDisplayName(template)}
|
||||||
</h3>
|
</h3>
|
||||||
<span className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold ${appearance.pillClassName}`}>
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold ${appearance.pillClassName}`}>
|
||||||
{isPublished && (
|
{isPublished && (
|
||||||
<span className={`h-2.5 w-2.5 rounded-full ${isRuntimeEnabled ? 'bg-current opacity-80' : 'bg-current opacity-55'}`} />
|
<span className={`h-2 w-2 rounded-full ${isRuntimeEnabled ? 'bg-current opacity-80' : 'bg-current opacity-55'}`} />
|
||||||
)}
|
)}
|
||||||
{appearance.pillLabel}
|
{appearance.pillLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 max-w-[34ch] text-sm leading-7 text-gray-400">
|
<p className="mt-1.5 max-w-[28ch] text-[12.5px] leading-5 text-gray-400">
|
||||||
{appearance.description}
|
{appearance.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -328,38 +348,38 @@ export default function Templates() {
|
||||||
aria-label={`Set runtime ${isRuntimeEnabled ? 'paused' : 'active'} for ${getTemplateDisplayName(template)}`}
|
aria-label={`Set runtime ${isRuntimeEnabled ? 'paused' : 'active'} for ${getTemplateDisplayName(template)}`}
|
||||||
disabled={!isPublished || isRuntimeUpdating}
|
disabled={!isPublished || isRuntimeUpdating}
|
||||||
onClick={() => isPublished && handleRuntimeToggle(template)}
|
onClick={() => isPublished && handleRuntimeToggle(template)}
|
||||||
className={`relative inline-flex h-7 w-12 items-center rounded-full border transition ${
|
className={`relative inline-flex h-6 w-10 items-center rounded-full border transition ${
|
||||||
isPublished
|
isPublished
|
||||||
? `${appearance.switchTrackClassName} ${isRuntimeUpdating ? 'cursor-wait opacity-70' : 'cursor-pointer'}`
|
? `${appearance.switchTrackClassName} ${isRuntimeUpdating ? 'cursor-wait opacity-70' : 'cursor-pointer'}`
|
||||||
: 'cursor-not-allowed border-[#d8dee8] bg-[#eef1f5] opacity-95'
|
: 'cursor-not-allowed border-[#d8dee8] bg-[#eef1f5] opacity-95'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||||
isPublished && isRuntimeEnabled ? 'translate-x-6' : 'translate-x-1'
|
isPublished && isRuntimeEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 border-t border-gray-100 pt-5">
|
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="flex items-end justify-between gap-3">
|
||||||
<div className="flex flex-wrap items-start gap-8">
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-400">Profile</p>
|
<p className="text-[11px] font-medium text-gray-400">Profile</p>
|
||||||
<p className="mt-1 text-base font-semibold text-gray-900">
|
<p className="mt-0.5 text-[13px] font-semibold text-gray-900">
|
||||||
{getBoundProfileSummary(template, boundProfile)}
|
{getBoundProfileSummary(template, boundProfile)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-400">DLT Template ID</p>
|
<p className="text-[11px] font-medium text-gray-400">DLT Template ID</p>
|
||||||
<p className="mt-1 font-mono text-sm font-semibold text-gray-900">
|
<p className="mt-0.5 font-mono text-[11px] font-semibold text-gray-900">
|
||||||
{formatDltTemplateId(template.templateId)}
|
{formatDltTemplateId(template.templateId)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setWorkspaceSlug(template.eventSlug)}
|
onClick={() => setWorkspaceSlug(template.eventSlug)}
|
||||||
|
|
@ -372,7 +392,7 @@ export default function Templates() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setWhitelistTarget(template)}
|
onClick={() => setWhitelistTarget(template)}
|
||||||
className="rounded-full border border-orange-200 bg-[#fff4ea] px-4 py-2 text-sm font-semibold text-orange-700 transition hover:border-orange-300 hover:bg-[#ffeddc]"
|
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]"
|
||||||
>
|
>
|
||||||
Publish
|
Publish
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -382,7 +402,7 @@ export default function Templates() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTestTarget(template)}
|
onClick={() => setTestTarget(template)}
|
||||||
className="rounded-full border border-[#c7d6ff] bg-[#f5f8ff] px-4 py-2 text-sm font-semibold text-[#4563d5] transition hover:border-[#afc3ff] hover:bg-[#ebf1ff]"
|
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]"
|
||||||
>
|
>
|
||||||
Test SMS
|
Test SMS
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -391,7 +411,7 @@ export default function Templates() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isBoundProfileMissing && (
|
{isBoundProfileMissing && (
|
||||||
<p className="mt-4 text-sm font-medium leading-6 text-gray-500">
|
<p className="mt-3 text-[13px] font-medium leading-5 text-gray-500">
|
||||||
{template.curlProfileId
|
{template.curlProfileId
|
||||||
? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.'
|
? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.'
|
||||||
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.'}
|
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.'}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,16 @@ function firstNonEmptyText(...values) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScrapedLogoUrl(entity = {}) {
|
||||||
|
const branding = entity?.scrapeArtifacts?.json?.branding;
|
||||||
|
|
||||||
|
return firstNonEmptyText(
|
||||||
|
branding?.primaryLogoUrl,
|
||||||
|
Array.isArray(branding?.logoCandidates) ? branding.logoCandidates[0] : '',
|
||||||
|
Array.isArray(branding?.logos) ? branding.logos[0] : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function extractDomainName(domain) {
|
function extractDomainName(domain) {
|
||||||
if (!domain) return '';
|
if (!domain) return '';
|
||||||
if (typeof domain === 'string') return normalizeText(domain);
|
if (typeof domain === 'string') return normalizeText(domain);
|
||||||
|
|
@ -170,11 +180,14 @@ export function getBusinessTagline(entity) {
|
||||||
|
|
||||||
export function getBusinessImage(entity) {
|
export function getBusinessImage(entity) {
|
||||||
const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
|
const relevantImage = normalizeList(entity?.relevantImagePaths)[0];
|
||||||
if (relevantImage) return relevantImage;
|
const scrapedLogo = getScrapedLogoUrl(entity);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
entity?.imageUrl
|
entity?.logoUrl
|
||||||
|| entity?.logoUrl
|
|| scrapedLogo
|
||||||
|
|| entity?.imageUrl
|
||||||
|
|| entity?.previewImagePath
|
||||||
|
|| relevantImage
|
||||||
|| entity?.brandImageUrl
|
|| entity?.brandImageUrl
|
||||||
|| entity?.image
|
|| entity?.image
|
||||||
|| ''
|
|| ''
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage');
|
const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage');
|
||||||
|
|
||||||
const DEFAULT_TABLE_NAME = 'fdk session storage';
|
const DEFAULT_TABLE_NAME = 'fdk session storage_SMS';
|
||||||
|
|
||||||
function normalizeText(value) {
|
function normalizeText(value) {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,535 @@
|
||||||
|
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,6 +6,38 @@ function normalizeList(value) {
|
||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbsoluteHttpUrl(value) {
|
||||||
|
const normalized = normalizeText(value);
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(normalized);
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectImageUrls(value, bucket = []) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((entry) => collectImageUrls(entry, bucket));
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (isAbsoluteHttpUrl(value)) bucket.push(value);
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = normalizeText(value.url || value.href || value.src || value.secure_url);
|
||||||
|
if (isAbsoluteHttpUrl(url)) bucket.push(url);
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueStrings(values) {
|
function uniqueStrings(values) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return normalizeList(values)
|
return normalizeList(values)
|
||||||
|
|
@ -76,6 +108,46 @@ function dedupeLinks(links) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreLogoCandidate(url) {
|
||||||
|
const normalized = normalizeText(url).toLowerCase();
|
||||||
|
if (!normalized) return -100;
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (/logo|brandmark|wordmark|logomark/.test(normalized)) score += 40;
|
||||||
|
if (/favicon|apple-touch-icon|android-chrome|mstile|site-icon|siteicon|icon/.test(normalized)) score += 25;
|
||||||
|
if (/header|navbar|nav/.test(normalized)) score += 8;
|
||||||
|
|
||||||
|
if (/hero|banner|carousel|slider|product|collection|catalog|lookbook/.test(normalized)) score -= 35;
|
||||||
|
if (/social|facebook|instagram|twitter|linkedin|youtube|pinterest|avatar|profile/.test(normalized)) score -= 25;
|
||||||
|
if (/sprite|tracking|pixel|placeholder/.test(normalized)) score -= 40;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankLogoCandidates(values) {
|
||||||
|
return uniqueStrings(values)
|
||||||
|
.map((url, index) => ({
|
||||||
|
index,
|
||||||
|
score: scoreLogoCandidate(url),
|
||||||
|
url,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.score - left.score || left.index - right.index)
|
||||||
|
.map((entry) => entry.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMetadataImageCandidates(metadata = {}) {
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
Object.entries(metadata || {}).forEach(([key, value]) => {
|
||||||
|
const normalizedKey = normalizeText(key).toLowerCase();
|
||||||
|
if (!/(image|logo|icon|favicon|thumbnail|apple)/.test(normalizedKey)) return;
|
||||||
|
collectImageUrls(value, candidates);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rankLogoCandidates(candidates).filter((url) => scoreLogoCandidate(url) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function summarizePage(page, pageType) {
|
function summarizePage(page, pageType) {
|
||||||
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
|
const metadata = page?.metadata && typeof page.metadata === 'object' ? page.metadata : {};
|
||||||
|
|
||||||
|
|
@ -101,7 +173,7 @@ function buildRepresentativeTextBlocks(homepage, aboutPage, productPages) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenBranding(homepage) {
|
function flattenBranding(homepage, topImages = []) {
|
||||||
const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {};
|
const branding = homepage?.branding && typeof homepage.branding === 'object' ? homepage.branding : {};
|
||||||
const colorEntries = [];
|
const colorEntries = [];
|
||||||
const logos = [];
|
const logos = [];
|
||||||
|
|
@ -142,12 +214,22 @@ function flattenBranding(homepage) {
|
||||||
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
|
const brandName = normalizeText(branding.brandName || branding.brand_name || branding.name);
|
||||||
if (brandName) brandNames.push(brandName);
|
if (brandName) brandNames.push(brandName);
|
||||||
|
|
||||||
|
const metadataImageCandidates = extractMetadataImageCandidates(homepage?.metadata || {});
|
||||||
|
const topLogoCandidates = rankLogoCandidates(topImages).filter((url) => scoreLogoCandidate(url) > 0);
|
||||||
|
const logoCandidates = uniqueStrings([
|
||||||
|
...logos,
|
||||||
|
...metadataImageCandidates,
|
||||||
|
...topLogoCandidates,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
|
colors: uniqueStrings(colorEntries.map((entry) => entry.hex)),
|
||||||
labeledColors: colorEntries.filter((entry, index, values) => (
|
labeledColors: colorEntries.filter((entry, index, values) => (
|
||||||
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
|
values.findIndex((candidate) => candidate.name === entry.name && candidate.hex === entry.hex) === index
|
||||||
)),
|
)),
|
||||||
logos: uniqueStrings(logos),
|
logos: uniqueStrings(logos),
|
||||||
|
logoCandidates,
|
||||||
|
primaryLogoUrl: logoCandidates[0] || '',
|
||||||
brandNames: uniqueStrings(brandNames),
|
brandNames: uniqueStrings(brandNames),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +270,7 @@ function buildCrawlSummary(data = {}, startUrlOverride = '') {
|
||||||
...normalizeList(aboutRaw?.images),
|
...normalizeList(aboutRaw?.images),
|
||||||
...productRawPages.flatMap((page) => normalizeList(page?.images)),
|
...productRawPages.flatMap((page) => normalizeList(page?.images)),
|
||||||
]).slice(0, 60);
|
]).slice(0, 60);
|
||||||
const branding = flattenBranding(homepageRaw);
|
const branding = flattenBranding(homepageRaw, topImages);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startUrl,
|
startUrl,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,821 @@
|
||||||
|
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