fixing route checks in pixelbin
This commit is contained in:
parent
7acb26602e
commit
4f9fd36610
|
|
@ -59,30 +59,43 @@ function extractCdnUrls(business) {
|
|||
return normalizeUniqueStrings(business?.relevantImagePaths);
|
||||
}
|
||||
|
||||
function normalizeScrapeLinks(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const DISPLAY_JSON_OMIT_KEYS = new Set([
|
||||
'cdnUrls',
|
||||
'href',
|
||||
'link',
|
||||
'links',
|
||||
'logos',
|
||||
'navigation',
|
||||
'screenshots',
|
||||
'socialLinks',
|
||||
'startUrl',
|
||||
'summaryText',
|
||||
'topImages',
|
||||
'url',
|
||||
]);
|
||||
|
||||
const seen = new Set();
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
const href = normalizeText(entry);
|
||||
return href ? { href, label: href } : null;
|
||||
}
|
||||
function sanitizeDisplayJson(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => sanitizeDisplayJson(entry))
|
||||
.filter((entry) => entry !== undefined);
|
||||
}
|
||||
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const href = normalizeText(entry.href || entry.url || entry.link);
|
||||
if (!href) return null;
|
||||
const sanitized = {};
|
||||
Object.entries(value).forEach(([key, entryValue]) => {
|
||||
if (DISPLAY_JSON_OMIT_KEYS.has(key)) return;
|
||||
|
||||
const label = normalizeText(entry.text || entry.title || entry.label || href);
|
||||
return { href, label };
|
||||
})
|
||||
.filter((entry) => {
|
||||
if (!entry || seen.has(entry.href)) return false;
|
||||
seen.add(entry.href);
|
||||
return true;
|
||||
});
|
||||
const nextValue = sanitizeDisplayJson(entryValue);
|
||||
if (nextValue !== undefined) {
|
||||
sanitized[key] = nextValue;
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function formatPrettyJson(value) {
|
||||
|
|
@ -131,7 +144,7 @@ function extractAboutText(business) {
|
|||
const homepageExcerpt = normalizeText(scrapeJson?.homepage?.excerpt);
|
||||
if (homepageExcerpt) return homepageExcerpt;
|
||||
|
||||
return normalizeText(scrapeJson?.summaryText);
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function BusinessReviewModal({ business, onClose }) {
|
||||
|
|
@ -144,8 +157,7 @@ export default function BusinessReviewModal({ business, onClose }) {
|
|||
const colors = extractColors(business);
|
||||
const aboutText = extractAboutText(business);
|
||||
const cdnUrls = extractCdnUrls(business);
|
||||
const links = normalizeScrapeLinks(business?.scrapeArtifacts?.links);
|
||||
const prettyJson = useMemo(() => formatPrettyJson(business?.scrapeArtifacts?.json), [business]);
|
||||
const prettyJson = useMemo(() => formatPrettyJson(sanitizeDisplayJson(business?.scrapeArtifacts?.json)), [business]);
|
||||
|
||||
useEffect(() => {
|
||||
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">
|
||||
{cdnUrls.length} image{cdnUrls.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">
|
||||
{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">
|
||||
{colors.length} color{colors.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
|
|
@ -303,7 +312,7 @@ export default function BusinessReviewModal({ business, onClose }) {
|
|||
<section className="space-y-3">
|
||||
<div>
|
||||
<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 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">
|
||||
|
|
@ -312,31 +321,6 @@ export default function BusinessReviewModal({ business, onClose }) {
|
|||
</div>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import axios from 'axios';
|
||||
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 TemplateDetailWorkspaceModal from '../components/TemplateDetailWorkspaceModal';
|
||||
import { useBusiness } from '../context/BusinessContext';
|
||||
|
||||
const MAX_SMS_LENGTH = 160;
|
||||
|
|
@ -319,7 +320,7 @@ function buildTemplateUiState(templates = []) {
|
|||
return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug };
|
||||
}
|
||||
|
||||
function TemplateWorkspaceModal({
|
||||
function TemplateGenerationWorkspaceModal({
|
||||
eventSlug,
|
||||
eventLabel,
|
||||
statusConfig,
|
||||
|
|
@ -676,7 +677,6 @@ function TemplateWorkspaceModal({
|
|||
|
||||
export default function Events() {
|
||||
const { businessId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { refreshOnboardingState } = useBusiness();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -695,6 +695,7 @@ export default function Events() {
|
|||
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
|
||||
const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({});
|
||||
const [templateWorkspace, setTemplateWorkspace] = useState({ slug: '', sessionId: 0 });
|
||||
const [templateViewerSlug, setTemplateViewerSlug] = useState('');
|
||||
const [workspaceError, setWorkspaceError] = useState('');
|
||||
const [showClosePrompt, setShowClosePrompt] = useState(false);
|
||||
const [discardingWorkspace, setDiscardingWorkspace] = useState(false);
|
||||
|
|
@ -826,6 +827,10 @@ export default function Events() {
|
|||
setTemplateWorkspace({ slug: '', sessionId: 0 });
|
||||
}
|
||||
|
||||
function closeTemplateViewer() {
|
||||
setTemplateViewerSlug('');
|
||||
}
|
||||
|
||||
function getWorkspaceBaseState(slug) {
|
||||
if (selectedTemplateBySlug[slug]?.selectedTemplate) return 'selected';
|
||||
if ((variants[slug] || []).length > 0) return 'done';
|
||||
|
|
@ -1024,6 +1029,10 @@ export default function Events() {
|
|||
openTemplateWorkspace(slug);
|
||||
}
|
||||
|
||||
function handleOpenTemplateViewer(slug) {
|
||||
setTemplateViewerSlug(slug);
|
||||
}
|
||||
|
||||
function handleOpenGenerateWorkspace(slug) {
|
||||
const sessionId = openTemplateWorkspace(slug);
|
||||
handleGenerate(slug, { sessionId });
|
||||
|
|
@ -1376,8 +1385,10 @@ export default function Events() {
|
|||
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
||||
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
||||
const selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
|
||||
const canViewTemplate = templateStatus !== 'unselected';
|
||||
const hasWorkspaceContent = eventVariants.length > 0 || !!selectedTemplatePreview;
|
||||
const hasSelectedTemplate = !!selectedTemplatePreview;
|
||||
const hasDraftWorkspace = eventVariants.length > 0;
|
||||
const canOpenGenerationWorkspace = hasDraftWorkspace;
|
||||
const hasExistingWorkspace = hasSelectedTemplate || canOpenGenerationWorkspace;
|
||||
|
||||
return (
|
||||
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
|
||||
|
|
@ -1398,25 +1409,6 @@ export default function Events() {
|
|||
)}
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
|
@ -1429,28 +1421,33 @@ export default function Events() {
|
|||
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
{canViewTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
|
||||
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 "
|
||||
>
|
||||
View in Templates
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (hasWorkspaceContent ? handleOpenTemplateWorkspace(event.slug) : handleOpenGenerateWorkspace(event.slug))}
|
||||
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
|
||||
onClick={() => {
|
||||
if (hasSelectedTemplate) {
|
||||
handleOpenTemplateViewer(event.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canOpenGenerationWorkspace) {
|
||||
handleOpenTemplateWorkspace(event.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenGenerateWorkspace(event.slug);
|
||||
}}
|
||||
disabled={state === 'loading' || (!hasSelectedTemplate && !canOpenGenerationWorkspace && !readyToGenerate)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasExistingWorkspace
|
||||
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400'
|
||||
: 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
{state === 'loading' ? (
|
||||
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating…</>
|
||||
) : hasWorkspaceContent ? (
|
||||
<>Open Template</>
|
||||
) : hasSelectedTemplate ? (
|
||||
<>View Template</>
|
||||
) : canOpenGenerationWorkspace ? (
|
||||
<>Open Drafts</>
|
||||
) : (
|
||||
<>Generate Template</>
|
||||
)}
|
||||
|
|
@ -1471,7 +1468,7 @@ export default function Events() {
|
|||
</div>
|
||||
|
||||
{workspaceEvent && (
|
||||
<TemplateWorkspaceModal
|
||||
<TemplateGenerationWorkspaceModal
|
||||
eventSlug={workspaceEvent.slug}
|
||||
eventLabel={workspaceEvent.label}
|
||||
statusConfig={workspaceTemplateStatus === 'unselected' ? null : workspaceStatusConfig}
|
||||
|
|
@ -1511,6 +1508,18 @@ export default function Events() {
|
|||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 apiClient from '../api/client';
|
||||
import TemplateDetailWorkspaceModal from '../components/TemplateDetailWorkspaceModal';
|
||||
import WhitelistModal from '../components/WhitelistModal';
|
||||
import TestSmsModal from '../components/TestSmsModal';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
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' },
|
||||
whitelisted: { label: 'Published', bg: 'bg-white', text: 'text-gray-700', border: 'border-gray-200' },
|
||||
const MANAGEABLE_TEMPLATE_STATUSES = new Set(['pending_whitelisting', 'whitelisted']);
|
||||
|
||||
const CARD_APPEARANCE = {
|
||||
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() {
|
||||
const { businessId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -19,31 +88,28 @@ export default function Templates() {
|
|||
const [error, setError] = useState('');
|
||||
const [whitelistTarget, setWhitelistTarget] = 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 [workspaceSlug, setWorkspaceSlug] = useState('');
|
||||
const templateCardRefs = useRef({});
|
||||
const highlightTimeoutRef = useRef(null);
|
||||
const handledFocusSlugRef = useRef('');
|
||||
|
||||
const getTabForStatus = useCallback((status) => {
|
||||
if (status === 'pending_whitelisting') return 'pending';
|
||||
if (status === 'whitelisted') return 'published';
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const [templatesRes, profilesRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}/templates`),
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`).catch(() => ({ data: { profiles: [] } })),
|
||||
]);
|
||||
|
||||
const all = (templatesRes.data.templates || []).filter(t => t.selectedTemplate);
|
||||
const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map(profile => [profile.id, profile]));
|
||||
const allTemplates = (templatesRes.data.templates || []).filter((template) => template.selectedTemplate);
|
||||
const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map((profile) => [profile.id, profile]));
|
||||
|
||||
setTemplates(all);
|
||||
setTemplates(allTemplates);
|
||||
setProfilesById(profileMap);
|
||||
} catch {
|
||||
setError('Failed to load templates');
|
||||
|
|
@ -56,28 +122,38 @@ export default function Templates() {
|
|||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimeoutRef.current) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
useEffect(() => () => {
|
||||
if (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(() => {
|
||||
const targetEventSlug = searchParams.get('event');
|
||||
if (!targetEventSlug || templates.length === 0) return;
|
||||
if (!targetEventSlug || manageableTemplates.length === 0) 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;
|
||||
|
||||
const targetTab = getTabForStatus(targetTemplate.status);
|
||||
if (targetTab && activeTab !== targetTab) {
|
||||
setActiveTab(targetTab);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCard = templateCardRefs.current[targetEventSlug];
|
||||
if (!targetCard) return;
|
||||
|
||||
|
|
@ -90,195 +166,243 @@ export default function Templates() {
|
|||
}
|
||||
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setHighlightedEventSlug(currentSlug => (currentSlug === targetEventSlug ? '' : currentSlug));
|
||||
setHighlightedEventSlug((currentSlug) => (currentSlug === targetEventSlug ? '' : currentSlug));
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 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() {
|
||||
setWhitelistTarget(null);
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-indigo-600 rounded-full animate-spin" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="pb-5 mb-6 border-b border-gray-200">
|
||||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Templates</h1>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">Track whitelisting status and test your SMS templates.</p>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-6 border-b border-gray-200 pb-5">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-gray-800">Templates</h1>
|
||||
<p className="mt-1 text-sm font-medium text-gray-500">
|
||||
Manage template runtime, whitelisting, and testing from one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</button>
|
||||
<button type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4 mb-6 border-b border-border-main">
|
||||
<button
|
||||
onClick={() => setActiveTab('published')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'published'
|
||||
? 'border-primary-blue text-primary-dark'
|
||||
: 'border-transparent text-text-muted hover:text-text-primary hover:border-border-main'
|
||||
}`}
|
||||
>
|
||||
Published
|
||||
</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>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-sm font-semibold text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-16 bg-surface-white border border-border-main rounded-lg ">
|
||||
<div className="w-16 h-16 rounded-full bg-page-bg flex items-center justify-center mx-auto mb-4 border border-border-soft">
|
||||
<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>
|
||||
{manageableTemplates.length === 0 ? (
|
||||
<div className="rounded-lg border border-border-main bg-surface-white py-16 text-center">
|
||||
<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="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>
|
||||
<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">
|
||||
Generate and select templates in the Events section first.
|
||||
</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
const publishedTabs = templates.filter(t => t.status === 'whitelisted');
|
||||
const pendingTabs = templates.filter(t => t.status === 'pending_whitelisting');
|
||||
const visibleTemplates = activeTab === 'published' ? publishedTabs : pendingTabs;
|
||||
) : 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 "{searchTerm.trim()}".
|
||||
</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';
|
||||
|
||||
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, ' ')}
|
||||
return (
|
||||
<article
|
||||
key={template.eventSlug}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
templateCardRefs.current[template.eventSlug] = node;
|
||||
} else {
|
||||
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} ${
|
||||
highlightedEventSlug === template.eventSlug
|
||||
? '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>
|
||||
<p className="text-xs text-gray-500 font-mono mt-0.5">{tmpl.eventSlug}</p>
|
||||
<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>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold border ${statusCfg.bg} ${statusCfg.text} ${statusCfg.border}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
<p className="mt-3 max-w-[34ch] text-sm leading-7 text-gray-400">
|
||||
{appearance.description}
|
||||
</p>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{tmpl.variableMap && Object.keys(tmpl.variableMap).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Variable Mappings</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(tmpl.variableMap).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs bg-white border border-gray-200 rounded-md px-3 py-1.5">
|
||||
<span className="font-mono text-primary-dark font-bold">{key}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="font-medium text-gray-700">{val}</span>
|
||||
</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 items-center gap-3 pt-2">
|
||||
{!isBoundProfileMissing && tmpl.status === 'pending_whitelisting' && (
|
||||
<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
|
||||
onClick={() => setWhitelistTarget(tmpl)}
|
||||
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"
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
|
||||
|
||||
{!isBoundProfileMissing && isPublished && (
|
||||
<button
|
||||
onClick={() => setTestTarget(tmpl)}
|
||||
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"
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
{tmpl.status === 'pending_whitelisting' && !isBoundProfileMissing && (
|
||||
<p className="text-xs text-text-muted font-medium">Submit to the DLT portal, then complete publish from here.</p>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{whitelistTarget && (
|
||||
<WhitelistModal
|
||||
|
|
@ -297,6 +421,18 @@ export default function Templates() {
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
const rawValue = normalizeText(value);
|
||||
if (!rawValue) return '';
|
||||
|
|
@ -934,6 +946,13 @@ async function resolveTemplateRequest(context) {
|
|||
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(
|
||||
tmpl.selectedTemplate,
|
||||
context.shipment,
|
||||
|
|
@ -1670,6 +1689,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => {
|
|||
variableMap: existingTemplate?.variableMap && typeof existingTemplate.variableMap === 'object'
|
||||
? existingTemplate.variableMap
|
||||
: {},
|
||||
isRuntimeEnabled: isTemplateRuntimeEnabled(existingTemplate),
|
||||
selectedImagePath: existingTemplate?.selectedImagePath || '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
|
@ -1703,7 +1723,7 @@ router.get('/:businessId/templates', async (req, res) => {
|
|||
const templates = [];
|
||||
for (const slug of slugs) {
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (tmpl) templates.push(tmpl);
|
||||
if (tmpl) templates.push(withTemplateRuntimeDefaults(tmpl));
|
||||
}
|
||||
res.json({ templates });
|
||||
} catch (err) {
|
||||
|
|
@ -1717,12 +1737,39 @@ router.get('/:businessId/templates/:slug', async (req, res) => {
|
|||
const { businessId, slug } = req.params;
|
||||
const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
res.json(tmpl);
|
||||
res.json(withTemplateRuntimeDefaults(tmpl));
|
||||
} catch (err) {
|
||||
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
|
||||
router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
|
||||
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' });
|
||||
|
||||
tmpl.generatedVariants = [];
|
||||
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
res.json({ ok: true, template: tmpl });
|
||||
res.json({ ok: true, template: withTemplateRuntimeDefaults(tmpl) });
|
||||
} catch (err) {
|
||||
sendRouteError(res, err);
|
||||
}
|
||||
|
|
@ -1799,10 +1847,11 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
|||
tmpl.rawCurl = activeCurl;
|
||||
tmpl.processedCurl = processedCurl;
|
||||
tmpl.variableMap = variableMap;
|
||||
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
res.json(tmpl);
|
||||
res.json(withTemplateRuntimeDefaults(tmpl));
|
||||
} catch (err) {
|
||||
console.error('Select 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.status = 'whitelisted';
|
||||
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
res.json(tmpl);
|
||||
res.json(withTemplateRuntimeDefaults(tmpl));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
|
@ -1879,6 +1929,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
|||
// Mark template as whitelisted
|
||||
tmpl.templateId = normalizeText(templateId);
|
||||
tmpl.status = 'whitelisted';
|
||||
tmpl.isRuntimeEnabled = isTemplateRuntimeEnabled(tmpl);
|
||||
tmpl.updatedAt = new Date().toISOString();
|
||||
await uploadJSON(folder, slug, tmpl);
|
||||
|
||||
|
|
@ -1895,13 +1946,13 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => {
|
|||
return res.status(502).json({
|
||||
error: 'Template published but send failed',
|
||||
details: sendErr.message,
|
||||
template: tmpl,
|
||||
template: withTemplateRuntimeDefaults(tmpl),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: tmpl,
|
||||
template: withTemplateRuntimeDefaults(tmpl),
|
||||
sendResult,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const STORAGE_NAMESPACE = 'Omni-SMS Extension';
|
||||
const STORAGE_NAMESPACE = 'Omni-SMS_Extension';
|
||||
|
||||
function normalizeSegment(value) {
|
||||
return String(value || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user