sms-extension-1777538290/client/src/pages/Templates.jsx

439 lines
18 KiB
JavaScript

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 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();
const [templates, setTemplates] = useState([]);
const [profilesById, setProfilesById] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [whitelistTarget, setWhitelistTarget] = useState(null);
const [testTarget, setTestTarget] = useState(null);
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 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 allTemplates = (templatesRes.data.templates || []).filter((template) => template.selectedTemplate);
const profileMap = Object.fromEntries((profilesRes.data.profiles || []).map((profile) => [profile.id, profile]));
setTemplates(allTemplates);
setProfilesById(profileMap);
} catch {
setError('Failed to load templates');
} finally {
setLoading(false);
}
}, [businessId]);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
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 || manageableTemplates.length === 0) return;
if (handledFocusSlugRef.current === targetEventSlug) return;
const targetTemplate = manageableTemplates.find((template) => template.eventSlug === targetEventSlug);
if (!targetTemplate) return;
const targetCard = templateCardRefs.current[targetEventSlug];
if (!targetCard) return;
handledFocusSlugRef.current = targetEventSlug;
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedEventSlug(targetEventSlug);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedEventSlug((currentSlug) => (currentSlug === targetEventSlug ? '' : currentSlug));
highlightTimeoutRef.current = null;
}, 2200);
}, [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 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="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 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 type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
&times;
</button>
</div>
)}
<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>
{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="mt-2 text-sm font-medium text-text-muted">
Generate and select templates in the Events section first.
</p>
</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';
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>
<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>
<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 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
</button>
)}
{!isBoundProfileMissing && isPublished && (
<button
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]"
>
Test SMS
</button>
)}
</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>
</article>
);
})}
</div>
)}
{whitelistTarget && (
<WhitelistModal
businessId={businessId}
template={whitelistTarget}
boundProfile={profilesById[whitelistTarget.curlProfileId] || null}
onClose={() => setWhitelistTarget(null)}
onSuccess={handleWhitelistSuccess}
/>
)}
{testTarget && (
<TestSmsModal
businessId={businessId}
template={testTarget}
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>
);
}