fixing route checks in pixelbin

This commit is contained in:
Ritul Jadhav 2026-04-06 12:37:53 +05:30
parent 7acb26602e
commit 4f9fd36610
7 changed files with 1212 additions and 265 deletions

View File

@ -59,30 +59,43 @@ function extractCdnUrls(business) {
return normalizeUniqueStrings(business?.relevantImagePaths); return normalizeUniqueStrings(business?.relevantImagePaths);
} }
function normalizeScrapeLinks(value) { const DISPLAY_JSON_OMIT_KEYS = new Set([
if (!Array.isArray(value)) return []; 'cdnUrls',
'href',
'link',
'links',
'logos',
'navigation',
'screenshots',
'socialLinks',
'startUrl',
'summaryText',
'topImages',
'url',
]);
const seen = new Set(); function sanitizeDisplayJson(value) {
if (Array.isArray(value)) {
return value return value
.map((entry) => { .map((entry) => sanitizeDisplayJson(entry))
if (typeof entry === 'string') { .filter((entry) => entry !== undefined);
const href = normalizeText(entry);
return href ? { href, label: href } : null;
} }
if (!entry || typeof entry !== 'object') return null; if (!value || typeof value !== 'object') {
return value;
}
const href = normalizeText(entry.href || entry.url || entry.link); const sanitized = {};
if (!href) return null; Object.entries(value).forEach(([key, entryValue]) => {
if (DISPLAY_JSON_OMIT_KEYS.has(key)) return;
const label = normalizeText(entry.text || entry.title || entry.label || href); const nextValue = sanitizeDisplayJson(entryValue);
return { href, label }; if (nextValue !== undefined) {
}) sanitized[key] = nextValue;
.filter((entry) => { }
if (!entry || seen.has(entry.href)) return false;
seen.add(entry.href);
return true;
}); });
return sanitized;
} }
function formatPrettyJson(value) { function formatPrettyJson(value) {
@ -131,7 +144,7 @@ function extractAboutText(business) {
const homepageExcerpt = normalizeText(scrapeJson?.homepage?.excerpt); const homepageExcerpt = normalizeText(scrapeJson?.homepage?.excerpt);
if (homepageExcerpt) return homepageExcerpt; if (homepageExcerpt) return homepageExcerpt;
return normalizeText(scrapeJson?.summaryText); return '';
} }
export default function BusinessReviewModal({ business, onClose }) { export default function BusinessReviewModal({ business, onClose }) {
@ -144,8 +157,7 @@ export default function BusinessReviewModal({ business, onClose }) {
const colors = extractColors(business); const colors = extractColors(business);
const aboutText = extractAboutText(business); const aboutText = extractAboutText(business);
const cdnUrls = extractCdnUrls(business); const cdnUrls = extractCdnUrls(business);
const links = normalizeScrapeLinks(business?.scrapeArtifacts?.links); const prettyJson = useMemo(() => formatPrettyJson(sanitizeDisplayJson(business?.scrapeArtifacts?.json)), [business]);
const prettyJson = useMemo(() => formatPrettyJson(business?.scrapeArtifacts?.json), [business]);
useEffect(() => { useEffect(() => {
const previousBodyOverflow = document.body.style.overflow; const previousBodyOverflow = document.body.style.overflow;
@ -214,9 +226,6 @@ export default function BusinessReviewModal({ business, onClose }) {
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600"> <span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
{cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'} {cdnUrls.length} image{cdnUrls.length === 1 ? '' : 's'}
</span> </span>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
{links.length} link{links.length === 1 ? '' : 's'}
</span>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600"> <span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
{colors.length} color{colors.length === 1 ? '' : 's'} {colors.length} color{colors.length === 1 ? '' : 's'}
</span> </span>
@ -303,7 +312,7 @@ export default function BusinessReviewModal({ business, onClose }) {
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Captured Data</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Captured Data</p>
<p className="mt-1 text-sm text-gray-500">Raw storefront data captured during onboarding.</p> <p className="mt-1 text-sm text-gray-500">Captured storefront data, with link-heavy fields hidden in this review.</p>
</div> </div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950"> <div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100"> <pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
@ -312,31 +321,6 @@ export default function BusinessReviewModal({ business, onClose }) {
</div> </div>
</section> </section>
)} )}
{links.length > 0 && (
<section className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Links</p>
<p className="mt-1 text-sm text-gray-500">Every discovered storefront link is available below.</p>
</div>
<div className="rounded-xl border border-gray-200">
<div className="max-h-72 overflow-y-auto divide-y divide-gray-100">
{links.map((link, index) => (
<a
key={`${link.href}-${index}`}
href={link.href}
target="_blank"
rel="noreferrer"
className="block px-4 py-3 transition hover:bg-gray-50"
>
<p className="break-all text-sm font-medium text-gray-800">{link.label}</p>
<p className="mt-1 break-all text-xs text-primary-blue">{link.href}</p>
</a>
))}
</div>
</div>
</section>
)}
</div> </div>
</div> </div>

View File

@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'react';
import apiClient from '../api/client';
import {
buildTemplateSampleRender,
getTemplateSamplePayload,
getTemplateWorkspaceDescription,
} from '../utils/templateWorkspace';
const STATUS_LABELS = {
pending_whitelisting: 'Pending Whitelisting',
whitelisted: 'Published',
generated: 'Generated',
};
function getStatusLabel(status) {
return STATUS_LABELS[String(status || '').trim()] || 'Draft';
}
function buildProfileMap(profiles = []) {
return Object.fromEntries((profiles || []).map((profile) => [profile.id, profile]));
}
export default function TemplateDetailWorkspaceModal({
businessId,
templateSlug,
initialTemplate = null,
initialProfile = null,
onClose,
onRequestPublish,
onRequestTest,
}) {
const [template, setTemplate] = useState(initialTemplate);
const [profilesById, setProfilesById] = useState(initialProfile?.id ? { [initialProfile.id]: initialProfile } : {});
const [loading, setLoading] = useState(!initialTemplate);
const [error, setError] = useState('');
useEffect(() => {
setTemplate(initialTemplate);
}, [initialTemplate]);
useEffect(() => {
if (!initialProfile?.id) return;
setProfilesById((currentProfiles) => ({
...currentProfiles,
[initialProfile.id]: initialProfile,
}));
}, [initialProfile]);
useEffect(() => {
if (!templateSlug) return undefined;
const previousBodyOverflow = document.body.style.overflow;
const previousBodyOverscroll = document.body.style.overscrollBehavior;
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousHtmlOverscroll = document.documentElement.style.overscrollBehavior;
document.body.style.overflow = 'hidden';
document.body.style.overscrollBehavior = 'none';
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overscrollBehavior = 'none';
return () => {
document.body.style.overflow = previousBodyOverflow;
document.body.style.overscrollBehavior = previousBodyOverscroll;
document.documentElement.style.overflow = previousHtmlOverflow;
document.documentElement.style.overscrollBehavior = previousHtmlOverscroll;
};
}, [templateSlug]);
useEffect(() => {
if (!templateSlug) return undefined;
let cancelled = false;
async function loadWorkspace() {
setLoading(true);
setError('');
try {
const [templateRes, profilesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/templates/${templateSlug}`),
apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })),
]);
if (cancelled) return;
setTemplate(templateRes.data || null);
setProfilesById((currentProfiles) => ({
...currentProfiles,
...buildProfileMap(profilesRes.data?.profiles || []),
}));
} catch (err) {
if (cancelled) return;
setError(err.response?.data?.error || 'Failed to load template details');
} finally {
if (!cancelled) setLoading(false);
}
}
loadWorkspace();
return () => {
cancelled = true;
};
}, [businessId, templateSlug]);
const boundProfile = useMemo(() => {
const profileId = String(template?.curlProfileId || '').trim();
if (!profileId) return initialProfile || null;
return profilesById[profileId] || initialProfile || null;
}, [initialProfile, profilesById, template?.curlProfileId]);
const samplePayload = useMemo(() => getTemplateSamplePayload(template || {}), [template]);
const previewState = useMemo(
() => buildTemplateSampleRender(template?.selectedTemplate, template?.variableMap, samplePayload),
[samplePayload, template?.selectedTemplate, template?.variableMap],
);
const renderedPreview = previewState.text;
const description = useMemo(() => getTemplateWorkspaceDescription(template || {}), [template]);
const isPublished = template?.status === 'whitelisted';
const isPending = template?.status === 'pending_whitelisting';
const hasBoundProfile = !!boundProfile;
const canPublish = typeof onRequestPublish === 'function' && isPending && hasBoundProfile;
const canTest = typeof onRequestTest === 'function' && isPublished && hasBoundProfile;
const runtimeStateLabel = isPublished ? (template?.isRuntimeEnabled === false ? 'Paused' : 'Active') : 'Not live yet';
const provider = boundProfile?.provider || {};
const samplePayloadText = JSON.stringify(samplePayload, null, 2);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div
className="relative flex h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl"
style={{ width: '1120px', maxWidth: 'calc(100vw - 2rem)', height: 'min(88vh, 880px)' }}
>
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400">Template Workspace</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-gray-900">
{template?.eventLabel || template?.eventSlug || 'Template'}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-50 hover:text-gray-900"
>
Close
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
{loading ? (
<div className="flex min-h-[360px] items-center justify-center">
<div className="h-10 w-10 rounded-full border-2 border-gray-200 border-t-primary-blue animate-spin" />
</div>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.7fr)_320px]">
<div className="space-y-6">
{error && (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700">
{error}
</div>
)}
<section>
<h3 className="text-3xl font-semibold tracking-tight text-gray-900">Content</h3>
</section>
<section className="overflow-hidden rounded-2xl border border-gray-200 bg-white">
<div className="flex items-center justify-between bg-primary-blue px-4 py-3 text-white">
<div>
<p className="text-sm font-semibold">Sample Payload</p>
<p className="text-xs text-indigo-100">Hardcoded event payload for previewing this template</p>
</div>
<span className="rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em]">
JSON
</span>
</div>
<pre className="max-h-[320px] overflow-auto bg-white px-5 py-4 text-xs leading-6 text-gray-700">
{samplePayloadText}
</pre>
</section>
<div className="grid gap-5 xl:grid-cols-2">
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Message</p>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
{String(template?.selectedTemplate || '').length} characters
</span>
</div>
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
<p className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-gray-800">
{template?.selectedTemplate || 'No selected template yet.'}
</p>
</div>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Preview</p>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
Sample render
</span>
</div>
<div className="min-h-[180px] rounded-2xl border border-gray-200 bg-white px-4 py-4">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-800">
{renderedPreview || template?.selectedTemplate || 'Preview unavailable.'}
</p>
</div>
</section>
</div>
</div>
<aside className="space-y-6 rounded-2xl border border-gray-200 bg-gray-50/70 p-5">
<section>
<h3 className="text-3xl font-semibold tracking-tight text-gray-900">Details</h3>
</section>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Template Name</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{template?.eventLabel || template?.eventSlug || 'Template'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">DLT Template ID</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 font-mono text-sm text-gray-700">
{template?.templateId || 'Pending DLT registration'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Description</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base leading-relaxed text-gray-700">
{description}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Status</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{getStatusLabel(template?.status)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Runtime</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{runtimeStateLabel}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Bound Profile</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
<p className="font-semibold text-gray-800">{boundProfile?.name || 'No bound profile'}</p>
{boundProfile?.id && (
<p className="mt-1 font-mono text-xs text-gray-500">{boundProfile.id}</p>
)}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Provider</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
{provider.providerName || 'Not configured'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">Sender ID</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 font-mono text-sm text-gray-700">
{provider.senderId || 'Missing sender ID'}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-500">DLT Entity ID</label>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 font-mono text-sm text-gray-700">
{provider.dltEntityId || 'Missing DLT entity ID'}
</div>
</div>
</div>
</aside>
</div>
)}
</div>
{(canPublish || canTest) && (
<div className="shrink-0 flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4">
{canPublish && (
<button
type="button"
onClick={() => onRequestPublish?.(template, boundProfile)}
className="rounded-lg bg-tags-text px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700"
>
Publish
</button>
)}
{canTest && (
<button
type="button"
onClick={() => onRequestTest?.(template)}
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
>
Test SMS
</button>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import TemplateDetailWorkspaceModal from '../components/TemplateDetailWorkspaceModal';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const MAX_SMS_LENGTH = 160; const MAX_SMS_LENGTH = 160;
@ -319,7 +320,7 @@ function buildTemplateUiState(templates = []) {
return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug }; return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug };
} }
function TemplateWorkspaceModal({ function TemplateGenerationWorkspaceModal({
eventSlug, eventSlug,
eventLabel, eventLabel,
statusConfig, statusConfig,
@ -676,7 +677,6 @@ function TemplateWorkspaceModal({
export default function Events() { export default function Events() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate();
const { refreshOnboardingState } = useBusiness(); const { refreshOnboardingState } = useBusiness();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -695,6 +695,7 @@ export default function Events() {
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({}); const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({}); const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({});
const [templateWorkspace, setTemplateWorkspace] = useState({ slug: '', sessionId: 0 }); const [templateWorkspace, setTemplateWorkspace] = useState({ slug: '', sessionId: 0 });
const [templateViewerSlug, setTemplateViewerSlug] = useState('');
const [workspaceError, setWorkspaceError] = useState(''); const [workspaceError, setWorkspaceError] = useState('');
const [showClosePrompt, setShowClosePrompt] = useState(false); const [showClosePrompt, setShowClosePrompt] = useState(false);
const [discardingWorkspace, setDiscardingWorkspace] = useState(false); const [discardingWorkspace, setDiscardingWorkspace] = useState(false);
@ -826,6 +827,10 @@ export default function Events() {
setTemplateWorkspace({ slug: '', sessionId: 0 }); setTemplateWorkspace({ slug: '', sessionId: 0 });
} }
function closeTemplateViewer() {
setTemplateViewerSlug('');
}
function getWorkspaceBaseState(slug) { function getWorkspaceBaseState(slug) {
if (selectedTemplateBySlug[slug]?.selectedTemplate) return 'selected'; if (selectedTemplateBySlug[slug]?.selectedTemplate) return 'selected';
if ((variants[slug] || []).length > 0) return 'done'; if ((variants[slug] || []).length > 0) return 'done';
@ -1024,6 +1029,10 @@ export default function Events() {
openTemplateWorkspace(slug); openTemplateWorkspace(slug);
} }
function handleOpenTemplateViewer(slug) {
setTemplateViewerSlug(slug);
}
function handleOpenGenerateWorkspace(slug) { function handleOpenGenerateWorkspace(slug) {
const sessionId = openTemplateWorkspace(slug); const sessionId = openTemplateWorkspace(slug);
handleGenerate(slug, { sessionId }); handleGenerate(slug, { sessionId });
@ -1376,8 +1385,10 @@ export default function Events() {
const templateStatus = templateStatusBySlug[event.slug] || 'unselected'; const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected; const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null; const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
const canViewTemplate = templateStatus !== 'unselected'; const hasSelectedTemplate = !!selectedTemplatePreview;
const hasWorkspaceContent = eventVariants.length > 0 || !!selectedTemplatePreview; const hasDraftWorkspace = eventVariants.length > 0;
const canOpenGenerationWorkspace = hasDraftWorkspace;
const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace;
return ( return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden"> <div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
@ -1398,25 +1409,6 @@ export default function Events() {
)} )}
<div> <div>
<h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3> <h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
{selectedTemplatePreview && (
<div className="mt-3 max-w-2xl rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Selected Template</p>
<p className="mt-2 text-sm leading-relaxed text-gray-700 line-clamp-3 break-words">
{selectedTemplatePreview.selectedTemplate}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{selectedTemplatePreview.templateId ? (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
Template ID {selectedTemplatePreview.templateId}
</span>
) : (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-500">
Template ID pending
</span>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
@ -1429,28 +1421,33 @@ export default function Events() {
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} /> <span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
{statusConfig.label} {statusConfig.label}
</span> </span>
{canViewTemplate && (
<button <button
type="button" type="button"
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)} onClick={() => {
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition " if (hasSelectedTemplate) {
> handleOpenTemplateViewer(event.slug);
View in Templates return;
</button> }
)}
<button if (canOpenGenerationWorkspace) {
type="button" handleOpenTemplateWorkspace(event.slug);
onClick={() => (hasWorkspaceContent ? handleOpenTemplateWorkspace(event.slug) : handleOpenGenerateWorkspace(event.slug))} return;
disabled={state === 'loading' || (!hasWorkspaceContent && !readyToGenerate)} }
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasWorkspaceContent
handleOpenGenerateWorkspace(event.slug);
}}
disabled={state === 'loading' || (!hasSelectedTemplate && !canOpenGenerationWorkspace && !readyToGenerate)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasExistingWorkspace
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400' ? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400'
: 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50' : 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50'
}`} }`}
> >
{state === 'loading' ? ( {state === 'loading' ? (
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating</> <><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating</>
) : hasWorkspaceContent ? ( ) : hasSelectedTemplate ? (
<>Open Template</> <>View Template</>
) : canOpenGenerationWorkspace ? (
<>Open Drafts</>
) : ( ) : (
<>Generate Template</> <>Generate Template</>
)} )}
@ -1471,7 +1468,7 @@ export default function Events() {
</div> </div>
{workspaceEvent && ( {workspaceEvent && (
<TemplateWorkspaceModal <TemplateGenerationWorkspaceModal
eventSlug={workspaceEvent.slug} eventSlug={workspaceEvent.slug}
eventLabel={workspaceEvent.label} eventLabel={workspaceEvent.label}
statusConfig={workspaceTemplateStatus === 'unselected' ? null : workspaceStatusConfig} statusConfig={workspaceTemplateStatus === 'unselected' ? null : workspaceStatusConfig}
@ -1511,6 +1508,18 @@ export default function Events() {
onDiscardWorkspace={discardTemplateWorkspace} onDiscardWorkspace={discardTemplateWorkspace}
/> />
)} )}
{templateViewerSlug && (
<TemplateDetailWorkspaceModal
businessId={businessId}
templateSlug={templateViewerSlug}
initialTemplate={selectedTemplateBySlug[templateViewerSlug] ? {
...selectedTemplateBySlug[templateViewerSlug],
eventLabel: events.find((event) => event.slug === templateViewerSlug)?.label || selectedTemplateBySlug[templateViewerSlug]?.eventSlug || '',
} : null}
onClose={closeTemplateViewer}
/>
)}
</> </>
); );
} }

View File

@ -1,15 +1,84 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import TemplateDetailWorkspaceModal from '../components/TemplateDetailWorkspaceModal';
import WhitelistModal from '../components/WhitelistModal'; import WhitelistModal from '../components/WhitelistModal';
import TestSmsModal from '../components/TestSmsModal'; import TestSmsModal from '../components/TestSmsModal';
const STATUS_CONFIG = { const MANAGEABLE_TEMPLATE_STATUSES = new Set(['pending_whitelisting', 'whitelisted']);
generated: { label: 'Generated', bg: 'bg-page-bg', text: 'text-text-muted', border: 'border-border-main' },
pending_whitelisting: { label: 'Pending Whitelisting', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' }, const CARD_APPEARANCE = {
whitelisted: { label: 'Published', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' }, live: {
pillLabel: 'Live',
pillClassName: 'border-[#9dd3c5] bg-[#e9f6f1] text-[#2f7f74]',
accentClassName: 'border-l-[#2f7f74]',
switchTrackClassName: 'border-[#2f7f74] bg-[#2f7f74]',
description: 'Published template is active for runtime sending.',
},
paused: {
pillLabel: 'Paused',
pillClassName: 'border-[#d6dde6] bg-[#f3f6fa] text-[#5f6f82]',
accentClassName: 'border-l-[#8fa0b3]',
switchTrackClassName: 'border-[#8fa0b3] bg-[#8fa0b3]',
description: 'Published template is available, but runtime sending is paused.',
},
pending: {
pillLabel: 'Pending Whitelisting',
pillClassName: 'border-[#bfd0ff] bg-[#f2f6ff] text-[#4563d5]',
accentClassName: 'border-l-[#4563d5]',
switchTrackClassName: 'border-[#d8dee8] bg-[#eef1f5]',
description: 'Selected template is awaiting whitelisting before it can go live.',
},
}; };
function matchesTemplateSearch(template, profile, rawSearchTerm) {
const searchTerm = rawSearchTerm.trim().toLowerCase();
if (!searchTerm) return true;
const candidates = [
template?.eventLabel,
template?.eventSlug,
template?.templateId,
profile?.name,
profile?.id,
]
.map((value) => String(value || '').toLowerCase())
.filter(Boolean);
return candidates.some((value) => value.includes(searchTerm));
}
function getTemplateDisplayName(template) {
return template?.eventLabel || String(template?.eventSlug || '').replace(/_/g, ' ') || 'Template';
}
function getCardAppearance(template) {
if (template?.status === 'whitelisted') {
return template?.isRuntimeEnabled === false ? CARD_APPEARANCE.paused : CARD_APPEARANCE.live;
}
return CARD_APPEARANCE.pending;
}
function formatDltTemplateId(templateId) {
const value = String(templateId || '').trim();
return value || 'Pending';
}
function getBoundProfileSummary(template, profile) {
if (profile?.name) return profile.name;
if (template?.curlProfileId) return 'Profile missing';
return 'Not bound yet';
}
function getTemplateSortRank(template) {
if (template?.status === 'whitelisted') {
return template?.isRuntimeEnabled === false ? 1 : 0;
}
return 2;
}
export default function Templates() { export default function Templates() {
const { businessId } = useParams(); const { businessId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -19,31 +88,28 @@ export default function Templates() {
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);
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending' const [searchTerm, setSearchTerm] = useState('');
const [runtimeUpdatingSlug, setRuntimeUpdatingSlug] = useState('');
const [highlightedEventSlug, setHighlightedEventSlug] = useState(''); const [highlightedEventSlug, setHighlightedEventSlug] = useState('');
const [workspaceSlug, setWorkspaceSlug] = useState('');
const templateCardRefs = useRef({}); const templateCardRefs = useRef({});
const highlightTimeoutRef = useRef(null); const highlightTimeoutRef = useRef(null);
const handledFocusSlugRef = useRef(''); const handledFocusSlugRef = useRef('');
const getTabForStatus = useCallback((status) => {
if (status === 'pending_whitelisting') return 'pending';
if (status === 'whitelisted') return 'published';
return null;
}, []);
const loadTemplates = useCallback(async () => { const loadTemplates = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [templatesRes, profilesRes] = await Promise.all([ const [templatesRes, profilesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/templates`), apiClient.get(`/api/businesses/${businessId}/templates`),
apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })), apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })),
]); ]);
const all = (templatesRes.data.templates || []).filter(t => t.selectedTemplate); const allTemplates = (templatesRes.data.templates || []).filter((template) => template.selectedTemplate);
const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map(profile => [profile.id, profile])); const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map((profile) => [profile.id, profile]));
setTemplates(all); setTemplates(allTemplates);
setProfilesById(profileMap); setProfilesById(profileMap);
} catch { } catch {
setError('Failed to load templates'); setError('Failed to load templates');
@ -56,28 +122,38 @@ export default function Templates() {
loadTemplates(); loadTemplates();
}, [loadTemplates]); }, [loadTemplates]);
useEffect(() => { useEffect(() => () => {
return () => {
if (highlightTimeoutRef.current) { if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current); window.clearTimeout(highlightTimeoutRef.current);
} }
};
}, []); }, []);
const manageableTemplates = useMemo(
() => templates.filter((template) => MANAGEABLE_TEMPLATE_STATUSES.has(template?.status)),
[templates],
);
const visibleTemplates = useMemo(() => {
return manageableTemplates
.filter((template) => (
matchesTemplateSearch(template, template.curlProfileId ? profilesById[template.curlProfileId] || null : null, searchTerm)
))
.sort((leftTemplate, rightTemplate) => {
const rankDifference = getTemplateSortRank(leftTemplate) - getTemplateSortRank(rightTemplate);
if (rankDifference !== 0) return rankDifference;
return getTemplateDisplayName(leftTemplate).localeCompare(getTemplateDisplayName(rightTemplate));
});
}, [manageableTemplates, profilesById, searchTerm]);
useEffect(() => { useEffect(() => {
const targetEventSlug = searchParams.get('event'); const targetEventSlug = searchParams.get('event');
if (!targetEventSlug || templates.length === 0) return; if (!targetEventSlug || manageableTemplates.length === 0) return;
if (handledFocusSlugRef.current === targetEventSlug) return; if (handledFocusSlugRef.current === targetEventSlug) return;
const targetTemplate = templates.find(tmpl => tmpl.eventSlug === targetEventSlug); const targetTemplate = manageableTemplates.find((template) => template.eventSlug === targetEventSlug);
if (!targetTemplate) return; if (!targetTemplate) return;
const targetTab = getTabForStatus(targetTemplate.status);
if (targetTab && activeTab !== targetTab) {
setActiveTab(targetTab);
return;
}
const targetCard = templateCardRefs.current[targetEventSlug]; const targetCard = templateCardRefs.current[targetEventSlug];
if (!targetCard) return; if (!targetCard) return;
@ -90,195 +166,243 @@ export default function Templates() {
} }
highlightTimeoutRef.current = window.setTimeout(() => { highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedEventSlug(currentSlug => (currentSlug === targetEventSlug ? '' : currentSlug)); setHighlightedEventSlug((currentSlug) => (currentSlug === targetEventSlug ? '' : currentSlug));
highlightTimeoutRef.current = null; highlightTimeoutRef.current = null;
}, 2200); }, 2200);
}, [activeTab, getTabForStatus, searchParams, templates]); }, [manageableTemplates, searchParams]);
useEffect(() => {
if (!workspaceSlug) return;
if (manageableTemplates.some((template) => template.eventSlug === workspaceSlug)) return;
setWorkspaceSlug('');
}, [manageableTemplates, workspaceSlug]);
async function handleWhitelistSuccess() { async function handleWhitelistSuccess() {
setWhitelistTarget(null); setWhitelistTarget(null);
await loadTemplates(); await loadTemplates();
} }
async function handleRuntimeToggle(template) {
const nextRuntimeState = !(template?.isRuntimeEnabled !== false);
setRuntimeUpdatingSlug(template.eventSlug);
setError('');
try {
const res = await apiClient.patch(
`/api/businesses/${businessId}/templates/${template.eventSlug}/runtime`,
{ isRuntimeEnabled: nextRuntimeState },
);
setTemplates((currentTemplates) => currentTemplates.map((currentTemplate) => (
currentTemplate.eventSlug === template.eventSlug ? res.data : currentTemplate
)));
} catch (err) {
setError(err.response?.data?.error || 'Failed to update template runtime state');
} finally {
setRuntimeUpdatingSlug('');
}
}
const currentWorkspaceTemplate = workspaceSlug
? manageableTemplates.find((template) => template.eventSlug === workspaceSlug) || null
: null;
if (loading) { if (loading) {
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 rounded-full border-2 border-gray-200 border-t-indigo-600 animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="max-w-4xl mx-auto"> <div className="mx-auto max-w-6xl">
<div className="pb-5 mb-6 border-b border-gray-200"> <div className="mb-6 border-b border-gray-200 pb-5">
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Templates</h1> <h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
<p className="text-sm text-gray-500 mt-1 font-medium">Track whitelisting status and test your SMS templates.</p> <p className="mt-1 text-sm font-medium text-gray-500">
Manage template runtime, whitelisting, and testing from one place.
</p>
</div> </div>
{error && ( {error && (
<div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-error-text font-medium text-sm flex items-center justify-between"> <div className="mb-6 flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text">
{error} {error}
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">&times;</button> <button type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
&times;
</button>
</div> </div>
)} )}
<div className="flex space-x-4 mb-6 border-b border-border-main"> <div className="mb-6">
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search by event, profile, or DLT template ID"
className="w-full rounded-xl border border-gray-200 bg-white py-3 pl-4 pr-12 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-blue"
/>
{searchTerm && (
<button <button
onClick={() => setActiveTab('published')} type="button"
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${ onClick={() => setSearchTerm('')}
activeTab === 'published' className="absolute inset-y-0 right-3 flex items-center text-sm font-semibold text-gray-500 hover:text-gray-800"
? 'border-primary-blue text-primary-dark'
: 'border-transparent text-text-muted hover:text-text-primary hover:border-border-main'
}`}
> >
Published Clear
</button>
<button
onClick={() => setActiveTab('pending')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'pending'
? 'border-primary-blue text-primary-dark'
: 'border-transparent text-text-muted hover:text-text-primary hover:border-border-main'
}`}
>
Pending Whitelisting
</button> </button>
)}
</div>
</div> </div>
{templates.length === 0 ? ( {manageableTemplates.length === 0 ? (
<div className="text-center py-16 bg-surface-white border border-border-main rounded-lg "> <div className="rounded-lg border border-border-main bg-surface-white py-16 text-center">
<div className="w-16 h-16 rounded-full bg-page-bg flex items-center justify-center mx-auto mb-4 border border-border-soft"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full border border-border-soft bg-page-bg">
<svg className="w-8 h-8 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> <svg className="h-8 w-8 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div> </div>
<h3 className="text-lg font-bold text-text-primary">No Templates Yet</h3> <h3 className="text-lg font-bold text-text-primary">No Templates Yet</h3>
<p className="text-text-muted text-sm mt-2 font-medium">Generate and select templates in the Events section first.</p> <p className="mt-2 text-sm font-medium text-text-muted">
</div> Generate and select templates in the Events section first.
) : (() => {
const publishedTabs = templates.filter(t => t.status === 'whitelisted');
const pendingTabs = templates.filter(t => t.status === 'pending_whitelisting');
const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs;
if (visibleTemplates.length === 0) {
return (
<div className="text-center py-12 bg-surface-white border border-border-dashed rounded-lg">
<p className="text-text-muted text-sm font-medium">No templates in {activeTab === 'published' ? 'Published' : 'Pending'}.</p>
</div>
);
}
return (
<div className="space-y-4">
{visibleTemplates.map(tmpl => {
const statusCfg = STATUS_CONFIG[tmpl.status] || STATUS_CONFIG.generated;
const boundProfile = tmpl.curlProfileId ? profilesById[tmpl.curlProfileId] || null : null;
const isBoundProfileMissing = !boundProfile;
const boundProfileMessage = tmpl.curlProfileId
? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.'
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.';
return (
<div
key={tmpl.eventSlug}
ref={(node) => {
if (node) {
templateCardRefs.current[tmpl.eventSlug] = node;
} else {
delete templateCardRefs.current[tmpl.eventSlug];
}
}}
className={`rounded-lg bg-white border overflow-hidden transition-all duration-300 ${
highlightedEventSlug === tmpl.eventSlug
? 'border-primary-blue animate-pulse'
: 'border-gray-200'
}`}
>
<div className="px-6 py-4 border-b border-gray-100 bg-white flex items-center justify-between">
<div>
<h3 className="text-base font-bold text-gray-800 capitalize tracking-tight">
{tmpl.eventLabel || tmpl.eventSlug.replace(/_/g, ' ')}
</h3>
<p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-bold border ${statusCfg.bg} ${statusCfg.text} ${statusCfg.border}`}>
{statusCfg.label}
</span>
</div>
<div className="p-5 space-y-4">
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Bound cURL Profile</label>
{boundProfile ? (
<div className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white border border-gray-200 text-sm text-gray-700">
<span className="font-semibold">{boundProfile.name}</span>
<span className="text-gray-400 font-mono text-xs">{boundProfile.id}</span>
</div>
) : (
<div className="px-4 py-2 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 font-medium">
{boundProfileMessage}
</div>
)}
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Selected Template</label>
<div className="p-4 rounded-lg bg-white border border-gray-200 font-mono text-sm text-gray-800 leading-relaxed break-words">
{tmpl.selectedTemplate}
</div>
</div>
{tmpl.templateId && (
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">DLT Template ID</label>
<p className="font-mono text-sm text-primary-dark bg-white border border-gray-200 px-3 py-2 rounded-lg inline-block">
{tmpl.templateId}
</p> </p>
</div> </div>
)} ) : visibleTemplates.length === 0 ? (
<div className="rounded-lg border border-border-dashed bg-surface-white py-12 text-center">
<p className="text-sm font-medium text-text-muted">
No templates match &quot;{searchTerm.trim()}&quot;.
</p>
</div>
) : (
<div className="grid gap-5 md:grid-cols-2">
{visibleTemplates.map((template) => {
const appearance = getCardAppearance(template);
const boundProfile = template.curlProfileId ? profilesById[template.curlProfileId] || null : null;
const isRuntimeEnabled = template.isRuntimeEnabled !== false;
const isRuntimeUpdating = runtimeUpdatingSlug === template.eventSlug;
const isBoundProfileMissing = !boundProfile;
const isPublished = template.status === 'whitelisted';
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && ( return (
<div> <article
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label> key={template.eventSlug}
<div className="flex flex-wrap gap-2"> ref={(node) => {
{Object.entries(tmpl.variableMap).map(([key, val]) => ( if (node) {
<div key={key} className="flex items-center gap-2 text-xs bg-white border border-gray-200 rounded-md px-3 py-1.5"> templateCardRefs.current[template.eventSlug] = node;
<span className="font-mono text-primary-dark font-bold">{key}</span> } else {
<span className="text-gray-400"></span> delete templateCardRefs.current[template.eventSlug];
<span className="font-medium text-gray-700">{val}</span> }
</div> }}
))} 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} ${
</div> highlightedEventSlug === template.eventSlug
</div> ? 'ring-2 ring-primary-blue/30'
: 'hover:-translate-y-0.5 hover:shadow-[0_16px_34px_rgba(15,23,42,0.08)]'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[1.35rem] font-semibold tracking-tight text-gray-900">
{getTemplateDisplayName(template)}
</h3>
<span className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold ${appearance.pillClassName}`}>
{isPublished && (
<span className={`h-2.5 w-2.5 rounded-full ${isRuntimeEnabled ? 'bg-current opacity-80' : 'bg-current opacity-55'}`} />
)} )}
{appearance.pillLabel}
</span>
</div>
<p className="mt-3 max-w-[34ch] text-sm leading-7 text-gray-400">
{appearance.description}
</p>
</div>
<div className="flex items-center gap-3 pt-2">
{!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && (
<button <button
onClick={() => setWhitelistTarget(tmpl)} type="button"
className="px-4 py-2 rounded-lg bg-tags-text hover:bg-orange-700 text-white text-sm font-semibold transition border border-orange-600" role="switch"
aria-checked={isPublished ? isRuntimeEnabled : false}
aria-label={`Set runtime ${isRuntimeEnabled ? 'paused' : 'active'} for ${getTemplateDisplayName(template)}`}
disabled={!isPublished || isRuntimeUpdating}
onClick={() => isPublished && handleRuntimeToggle(template)}
className={`relative inline-flex h-7 w-12 items-center rounded-full border transition ${
isPublished
? `${appearance.switchTrackClassName} ${isRuntimeUpdating ? 'cursor-wait opacity-70' : 'cursor-pointer'}`
: 'cursor-not-allowed border-[#d8dee8] bg-[#eef1f5] opacity-95'
}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
isPublished && isRuntimeEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div className="mt-5 border-t border-gray-100 pt-5">
<div className="flex items-end justify-between gap-4">
<div className="flex flex-wrap items-start gap-8">
<div>
<p className="text-xs font-medium text-gray-400">Profile</p>
<p className="mt-1 text-base font-semibold text-gray-900">
{getBoundProfileSummary(template, boundProfile)}
</p>
</div>
<div>
<p className="text-xs font-medium text-gray-400">DLT Template ID</p>
<p className="mt-1 font-mono text-sm font-semibold text-gray-900">
{formatDltTemplateId(template.templateId)}
</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<button
type="button"
onClick={() => setWorkspaceSlug(template.eventSlug)}
className="text-sm font-semibold text-primary-blue transition hover:text-primary-dark"
>
View Template
</button>
{!isBoundProfileMissing && template.status === 'pending_whitelisting' && (
<button
type="button"
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]"
> >
Publish Publish
</button> </button>
)} )}
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
{!isBoundProfileMissing && isPublished && (
<button <button
onClick={() => setTestTarget(tmpl)} type="button"
className="px-4 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-semibold transition flex items-center gap-2" 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]"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Test SMS Test SMS
</button> </button>
)} )}
{tmpl.status === 'pending_whitelisting' && !isBoundProfileMissing && ( </div>
<p className="text-xs text-text-muted font-medium">Submit to the DLT portal, then complete publish from here.</p> </div>
{isBoundProfileMissing && (
<p className="mt-4 text-sm font-medium leading-6 text-gray-500">
{template.curlProfileId
? 'The cURL profile used for this template no longer exists. Re-select this template from Events to continue.'
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.'}
</p>
)} )}
</div> </div>
</div> </article>
</div>
); );
})} })}
</div> </div>
); )}
})()}
{whitelistTarget && ( {whitelistTarget && (
<WhitelistModal <WhitelistModal
@ -297,6 +421,18 @@ export default function Templates() {
onClose={() => setTestTarget(null)} onClose={() => setTestTarget(null)}
/> />
)} )}
{workspaceSlug && (
<TemplateDetailWorkspaceModal
businessId={businessId}
templateSlug={workspaceSlug}
initialTemplate={currentWorkspaceTemplate}
initialProfile={currentWorkspaceTemplate?.curlProfileId ? profilesById[currentWorkspaceTemplate.curlProfileId] || null : null}
onClose={() => setWorkspaceSlug('')}
onRequestPublish={(template) => setWhitelistTarget(template)}
onRequestTest={(template) => setTestTarget(template)}
/>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,449 @@
const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
const PLACEHOLDER_SAMPLE_FIELD_CANDIDATES = {
'{#var#}': ['firstName', 'customerName', 'fullName', 'brandName', 'eventDisplayName'],
'{#numeric#}': ['otp', 'amount', 'refundAmount', 'pincode', 'toNumber'],
'{#url#}': ['trackingUrl', 'url', 'trackUrl', 'trackingLink'],
'{#urlott#}': ['trackingUrl', 'url', 'trackUrl', 'trackingLink'],
'{#cbn#}': ['callbackNumber', 'toNumber', 'customerPhone', 'mobile', 'phone'],
'{#email#}': ['email', 'customerEmail'],
'{#alphanumeric#}': ['orderId', 'transactionId', 'shipmentId', 'awbNumber', 'awbNo'],
};
const EVENT_SAMPLE_OVERRIDES = {
payment_failed: {
shipment: {
payment_status: 'failed',
transaction_id: 'TXN9012457812',
amount: '2499',
failure_reason: 'UPI mandate expired',
},
},
payment_initiated: {
shipment: {
payment_status: 'initiated',
transaction_id: 'TXN9012457812',
amount: '2499',
},
},
refund_initiated: {
shipment: {
refund_status: 'initiated',
refund_amount: '2499',
refund_id: 'RFD1204982',
},
},
refund_completed: {
shipment: {
refund_status: 'completed',
refund_amount: '2499',
refund_id: 'RFD1204982',
},
},
out_for_delivery: {
shipment: {
otp: '482193',
estimated_delivery_slot: '6:00 PM to 8:00 PM',
},
},
delivery_attempt_failed: {
shipment: {
failure_reason: 'Customer unavailable',
callback_number: '919876543210',
},
},
delivery_done: {
shipment: {
delivered_at: '2026-04-06T14:18:00.000Z',
otp: '482193',
},
},
order_placed: {
shipment: {
payment_status: 'paid',
expected_dispatch_date: '2026-04-07',
},
},
};
function normalizeScalarText(value) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return '';
}
function firstNonEmptyText(...values) {
for (const value of values) {
const normalized = normalizeScalarText(value);
if (normalized) return normalized;
}
return '';
}
function toCamelCase(text) {
return String(text || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part, index) => {
const lower = part.toLowerCase();
return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
})
.join('');
}
function setValueIndexEntry(valueIndex, key, value) {
if (!key || valueIndex.has(key)) return;
valueIndex.set(key, value);
}
function normalizeRenderableValue(value) {
return normalizeScalarText(value).replace(/\s+/g, ' ').trim();
}
function splitFullName(value) {
const fullName = normalizeRenderableValue(value);
if (!fullName) return { firstName: '', lastName: '', fullName: '' };
const parts = fullName.split(/\s+/).filter(Boolean);
return {
firstName: parts[0] || '',
lastName: parts.length > 1 ? parts.slice(1).join(' ') : '',
fullName,
};
}
function indexPayloadValues(value, pathParts = [], valueIndex = new Map()) {
if (Array.isArray(value)) {
value.forEach((entry) => indexPayloadValues(entry, pathParts, valueIndex));
return valueIndex;
}
if (value && typeof value === 'object') {
Object.entries(value).forEach(([key, entry]) => {
indexPayloadValues(entry, [...pathParts, key], valueIndex);
});
return valueIndex;
}
const normalizedValue = normalizeRenderableValue(value);
if (!normalizedValue || pathParts.length === 0) return valueIndex;
const leafKey = toCamelCase(pathParts[pathParts.length - 1]);
const fullKey = toCamelCase(pathParts.join(' '));
setValueIndexEntry(valueIndex, leafKey, normalizedValue);
setValueIndexEntry(valueIndex, fullKey, normalizedValue);
return valueIndex;
}
function buildShipmentValueIndex(shipment) {
const valueIndex = indexPayloadValues(shipment);
const firstBag = shipment?.bags?.[0] || {};
const customerName = splitFullName(
firstNonEmptyText(
shipment?.user?.first_name && shipment?.user?.last_name
? `${shipment.user.first_name} ${shipment.user.last_name}`
: '',
shipment?.delivery_address?.name,
shipment?.delivery_address?.contact_person,
shipment?.billing_address?.name,
shipment?.billing_address?.contact_person,
)
);
const primaryTrackingUrl = firstNonEmptyText(
shipment?.delivery_partner_details?.track_url,
shipment?.meta?.tracking_url,
firstBag?.meta?.tracking_url,
shipment?.affiliate_details?.shipment_meta?.tracking_url,
);
const primaryAwbNumber = firstNonEmptyText(
shipment?.delivery_partner_details?.awb_no,
shipment?.meta?.awb_number,
firstBag?.meta?.awb_number,
);
const primaryCourierName = firstNonEmptyText(
shipment?.delivery_partner_details?.display_name,
shipment?.delivery_partner_details?.name,
shipment?.meta?.courier_partner_name,
firstBag?.meta?.dp_name,
);
const brandName = firstNonEmptyText(
shipment?.bags?.[0]?.brand?.brand_name,
shipment?.bags?.[0]?.item?.attributes?.brand_name,
shipment?.affiliate_details?.company_affiliate_tag,
);
const toNumber = firstNonEmptyText(
shipment?.user?.mobile,
shipment?.delivery_address?.phone,
shipment?.billing_address?.phone,
);
const emailAddress = firstNonEmptyText(
shipment?.user?.email,
shipment?.delivery_address?.email,
shipment?.billing_address?.email,
);
const eventKey = firstNonEmptyText(
shipment?.status,
shipment?.shipment_status?.status,
shipment?.shipment_status?.current_shipment_status,
);
const eventDisplayName = firstNonEmptyText(
shipment?.shipment_status?.display_name,
shipment?.shipment_status?.current_shipment_status,
);
const shipmentId = firstNonEmptyText(
shipment?.shipment_id,
shipment?.shipment_status?.shipment_id,
);
const resolvedFullName = firstNonEmptyText(
shipment?.user?.first_name || shipment?.user?.last_name
? `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
: '',
customerName.fullName,
);
const resolvedFirstName = firstNonEmptyText(shipment?.user?.first_name, customerName.firstName);
const resolvedLastName = firstNonEmptyText(shipment?.user?.last_name, customerName.lastName);
setValueIndexEntry(valueIndex, 'firstName', resolvedFirstName);
setValueIndexEntry(valueIndex, 'lastName', resolvedLastName);
setValueIndexEntry(valueIndex, 'fullName', resolvedFullName);
setValueIndexEntry(valueIndex, 'customerFirstName', resolvedFirstName);
setValueIndexEntry(valueIndex, 'customerLastName', resolvedLastName);
setValueIndexEntry(valueIndex, 'customerName', resolvedFullName);
setValueIndexEntry(valueIndex, 'phone', toNumber);
setValueIndexEntry(valueIndex, 'mobile', toNumber);
setValueIndexEntry(valueIndex, 'toNumber', toNumber);
setValueIndexEntry(valueIndex, 'customerPhone', toNumber);
setValueIndexEntry(valueIndex, 'customerMobile', toNumber);
setValueIndexEntry(valueIndex, 'email', emailAddress);
setValueIndexEntry(valueIndex, 'customerEmail', emailAddress);
setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'orderNumber', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'shipmentId', shipmentId);
setValueIndexEntry(valueIndex, 'event', eventKey);
setValueIndexEntry(valueIndex, 'status', eventKey);
setValueIndexEntry(valueIndex, 'eventDisplayName', eventDisplayName);
setValueIndexEntry(valueIndex, 'displayName', eventDisplayName);
setValueIndexEntry(valueIndex, 'brandName', brandName);
setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackingLink', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awb', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
setValueIndexEntry(valueIndex, 'courierName', primaryCourierName);
setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName);
return valueIndex;
}
function mergeDeep(baseValue, overrideValue) {
if (Array.isArray(baseValue) || Array.isArray(overrideValue)) {
return overrideValue !== undefined ? overrideValue : baseValue;
}
if (baseValue && typeof baseValue === 'object' && overrideValue && typeof overrideValue === 'object') {
const nextValue = { ...baseValue };
Object.entries(overrideValue).forEach(([key, value]) => {
nextValue[key] = key in nextValue ? mergeDeep(nextValue[key], value) : value;
});
return nextValue;
}
return overrideValue !== undefined ? overrideValue : baseValue;
}
function titleCaseFromSlug(slug) {
return String(slug || '')
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function isRenderablePreviewValueForToken(token, value) {
const normalizedValue = normalizeRenderableValue(value);
if (!normalizedValue) return false;
switch (token) {
case '{#numeric#}':
return /^\d+$/.test(normalizedValue);
case '{#url#}':
case '{#urlott#}':
return /^https?:\/\//i.test(normalizedValue);
case '{#cbn#}':
return /^\+?[0-9][0-9\s-]{5,}$/.test(normalizedValue);
case '{#email#}':
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedValue);
case '{#alphanumeric#}':
return /^[A-Za-z0-9]+$/.test(normalizedValue);
default:
return true;
}
}
function resolvePlaceholderSampleFallback(token, shipmentValueIndex) {
const candidateFields = PLACEHOLDER_SAMPLE_FIELD_CANDIDATES[token] || [];
for (const fieldName of candidateFields) {
const resolvedValue = shipmentValueIndex.get(fieldName) || '';
if (isRenderablePreviewValueForToken(token, resolvedValue)) {
return {
fieldName,
value: resolvedValue,
};
}
}
return null;
}
export function getTemplateSamplePayload(template = {}) {
const eventSlug = String(template?.eventSlug || '').trim();
const eventLabel = String(template?.eventLabel || '').trim() || titleCaseFromSlug(eventSlug);
const brandName = String(template?.brandName || '').trim() || 'Your Brand';
const override = EVENT_SAMPLE_OVERRIDES[eventSlug] || {};
const basePayload = {
payload: {
event: eventSlug,
company_id: 'dev_merchant_001',
application_id: 'application-demo-001',
shipment: {
application_id: 'application-demo-001',
order_id: 'FY5E53AFAA091115C235',
shipment_id: 'SHP784512',
status: eventSlug || 'order_placed',
shipment_status: {
status: eventSlug || 'order_placed',
current_shipment_status: eventSlug || 'order_placed',
display_name: eventLabel || 'Order Update',
shipment_id: 'SHP784512',
},
user: {
first_name: 'Aarav',
last_name: 'Sharma',
mobile: '919876543210',
email: 'aarav.sharma@example.com',
},
delivery_address: {
name: 'Aarav Sharma',
phone: '919876543210',
email: 'aarav.sharma@example.com',
city: 'Bengaluru',
pincode: '560001',
},
billing_address: {
name: 'Aarav Sharma',
phone: '919876543210',
email: 'aarav.sharma@example.com',
},
delivery_partner_details: {
display_name: 'Blue Dart',
track_url: 'https://tracking.example.com/SHP784512',
awb_no: '78451236985',
},
affiliate_details: {
affiliate_id: 'application-demo-001',
company_affiliate_tag: brandName,
shipment_meta: {
tracking_url: 'https://tracking.example.com/SHP784512',
courier_partner_name: 'Blue Dart',
},
},
meta: {
tracking_url: 'https://tracking.example.com/SHP784512',
awb_number: '78451236985',
courier_partner_name: 'Blue Dart',
},
bags: [
{
brand: { brand_name: brandName },
item: {
name: 'Midnight Duffle',
attributes: { brand_name: brandName },
},
meta: {
tracking_url: 'https://tracking.example.com/SHP784512',
awb_number: '78451236985',
},
},
],
},
},
};
return mergeDeep(basePayload, override);
}
export function buildTemplateSampleRender(templateText, variableMap = {}, samplePayload = {}) {
const text = String(templateText || '');
if (!text) {
return {
text: '',
fallbackPlaceholders: [],
unresolvedPlaceholders: [],
};
}
const shipment = samplePayload?.payload?.shipment || samplePayload?.shipment || {};
const shipmentValueIndex = buildShipmentValueIndex(shipment);
let placeholderIndex = 0;
const fallbackPlaceholders = [];
const unresolvedPlaceholders = [];
const renderedText = text.replace(DLT_PLACEHOLDER_REGEX, (token) => {
const mappingKey = `${token}[${placeholderIndex}]`;
const mappedFieldName = normalizeScalarText(variableMap?.[mappingKey] || variableMap?.[token]);
placeholderIndex += 1;
const resolvedMappedValue = mappedFieldName
? shipmentValueIndex.get(toCamelCase(mappedFieldName)) || ''
: '';
if (resolvedMappedValue) return resolvedMappedValue;
const fallback = resolvePlaceholderSampleFallback(token, shipmentValueIndex);
if (fallback) {
fallbackPlaceholders.push({
mappingKey,
token,
mappedFieldName,
sampleFieldName: fallback.fieldName,
});
return fallback.value;
}
unresolvedPlaceholders.push({
mappingKey,
token,
mappedFieldName,
});
return token;
});
return {
text: renderedText,
fallbackPlaceholders,
unresolvedPlaceholders,
};
}
export function renderTemplateWithSamplePayload(templateText, variableMap = {}, samplePayload = {}) {
return buildTemplateSampleRender(templateText, variableMap, samplePayload).text;
}
export function getTemplateWorkspaceDescription(template = {}) {
const eventLabel = String(template?.eventLabel || '').trim() || titleCaseFromSlug(template?.eventSlug);
if (!eventLabel) return 'Review the current SMS template, sample payload, and delivery details.';
return `Use this SMS template for ${eventLabel.toLowerCase()} notifications.`;
}
export function getTemplateWorkspaceVariableCount(template = {}) {
return (String(template?.selectedTemplate || '').match(DLT_PLACEHOLDER_REGEX) || []).length;
}

View File

@ -176,6 +176,18 @@ function normalizeProvider(provider = {}, fallbackUpdatedAt = null) {
}; };
} }
function isTemplateRuntimeEnabled(template = {}) {
return template?.isRuntimeEnabled !== false;
}
function withTemplateRuntimeDefaults(template) {
if (!template || typeof template !== 'object') return template;
return {
...template,
isRuntimeEnabled: isTemplateRuntimeEnabled(template),
};
}
function normalizeWebsiteUrl(value) { function normalizeWebsiteUrl(value) {
const rawValue = normalizeText(value); const rawValue = normalizeText(value);
if (!rawValue) return ''; if (!rawValue) return '';
@ -934,6 +946,13 @@ async function resolveTemplateRequest(context) {
throw createHttpError(404, 'Whitelisted template not found'); throw createHttpError(404, 'Whitelisted template not found');
} }
if (!isTemplateRuntimeEnabled(tmpl)) {
throw createHttpError(409, 'Template runtime is paused', {
code: 'RUNTIME_DISABLED',
template: withTemplateRuntimeDefaults(tmpl),
});
}
const resolvedTemplate = renderShipmentTemplate( const resolvedTemplate = renderShipmentTemplate(
tmpl.selectedTemplate, tmpl.selectedTemplate,
context.shipment, context.shipment,
@ -1670,6 +1689,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => {
variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object' variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object'
? existingTemplate.variableMap ? existingTemplate.variableMap
: {}, : {},
isRuntimeEnabled: isTemplateRuntimeEnabled(existingTemplate),
selectedImagePath: existingTemplate?.selectedImagePath || '', selectedImagePath: existingTemplate?.selectedImagePath || '',
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
@ -1703,7 +1723,7 @@ router.get('/:businessId/templates', async (req, res) => {
const templates = []; const templates = [];
for (const slug of slugs) { for (const slug of slugs) {
const tmpl = await fetchJSON(folder, slug); const tmpl = await fetchJSON(folder, slug);
if (tmpl) templates.push(tmpl); if (tmpl) templates.push(withTemplateRuntimeDefaults(tmpl));
} }
res.json({ templates }); res.json({ templates });
} catch (err) { } catch (err) {
@ -1717,12 +1737,39 @@ router.get('/:businessId/templates/:slug', async (req, res) => {
const { businessId, slug } = req.params; const { businessId, slug } = req.params;
const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug); const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' }); if (!tmpl) return res.status(404).json({ error: 'Template not found' });
res.json(tmpl); res.json(withTemplateRuntimeDefaults(tmpl));
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
// PATCH /api/businesses/:businessId/templates/:slug/runtime
router.patch('/:businessId/templates/:slug/runtime', async (req, res) => {
try {
const { businessId, slug } = req.params;
const nextRuntimeState = req.body?.isRuntimeEnabled;
if (typeof nextRuntimeState !== 'boolean') {
return res.status(400).json({ error: 'isRuntimeEnabled must be a boolean' });
}
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
const tmpl = await fetchJSON(folder, slug);
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
if (tmpl.status !== 'whitelisted') {
return res.status(400).json({ error: 'Only published templates can change runtime state' });
}
tmpl.isRuntimeEnabled = nextRuntimeState;
tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl);
res.json(withTemplateRuntimeDefaults(tmpl));
} catch (err) {
sendRouteError(res, err);
}
});
// POST /api/businesses/:businessId/templates/:slug/validate-edit // POST /api/businesses/:businessId/templates/:slug/validate-edit
router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => { router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
try { try {
@ -1761,10 +1808,11 @@ router.post('/:businessId/templates/:slug/discard', async (req, res) => {
if (!tmpl) return res.status(404).json({ error: 'Template not found' }); if (!tmpl) return res.status(404).json({ error: 'Template not found' });
tmpl.generatedVariants = []; tmpl.generatedVariants = [];
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString(); tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl); await uploadJSON(folder, slug, tmpl);
res.json({ ok: true, template: tmpl }); res.json({ ok: true, template: withTemplateRuntimeDefaults(tmpl) });
} catch (err) { } catch (err) {
sendRouteError(res, err); sendRouteError(res, err);
} }
@ -1799,10 +1847,11 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => {
tmpl.rawCurl = activeCurl; tmpl.rawCurl = activeCurl;
tmpl.processedCurl = processedCurl; tmpl.processedCurl = processedCurl;
tmpl.variableMap = variableMap; tmpl.variableMap = variableMap;
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString(); tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl); await uploadJSON(folder, slug, tmpl);
res.json(tmpl); res.json(withTemplateRuntimeDefaults(tmpl));
} catch (err) { } catch (err) {
console.error('Select error:', err.message); console.error('Select error:', err.message);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
@ -1827,10 +1876,11 @@ router.post('/:businessId/templates/:slug/whitelist', async (req, res) => {
tmpl.templateId = String(templateId).trim(); tmpl.templateId = String(templateId).trim();
tmpl.status = 'whitelisted'; tmpl.status = 'whitelisted';
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString(); tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl); await uploadJSON(folder, slug, tmpl);
res.json(tmpl); res.json(withTemplateRuntimeDefaults(tmpl));
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
@ -1879,6 +1929,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
// Mark template as whitelisted // Mark template as whitelisted
tmpl.templateId = normalizeText(templateId); tmpl.templateId = normalizeText(templateId);
tmpl.status = 'whitelisted'; tmpl.status = 'whitelisted';
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
tmpl.updatedAt = new Date().toISOString(); tmpl.updatedAt = new Date().toISOString();
await uploadJSON(folder, slug, tmpl); await uploadJSON(folder, slug, tmpl);
@ -1895,13 +1946,13 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
return res.status(502).json({ return res.status(502).json({
error: 'Template published but send failed', error: 'Template published but send failed',
details: sendErr.message, details: sendErr.message,
template: tmpl, template: withTemplateRuntimeDefaults(tmpl),
}); });
} }
res.json({ res.json({
success: true, success: true,
template: tmpl, template: withTemplateRuntimeDefaults(tmpl),
sendResult, sendResult,
}); });
} catch (err) { } catch (err) {

View File

@ -1,4 +1,4 @@
const STORAGE_NAMESPACE = 'Omni-SMS Extension'; const STORAGE_NAMESPACE = 'Omni-SMS_Extension';
function normalizeSegment(value) { function normalizeSegment(value) {
return String(value || '').trim().replace(/^\/+|\/+$/g, ''); return String(value || '').trim().replace(/^\/+|\/+$/g, '');