303 lines
14 KiB
JavaScript
303 lines
14 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useParams, useSearchParams } from 'react-router-dom';
|
|
import apiClient from '../api/client';
|
|
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' },
|
|
};
|
|
|
|
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 [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
|
const [highlightedEventSlug, setHighlightedEventSlug] = 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]));
|
|
|
|
setTemplates(all);
|
|
setProfilesById(profileMap);
|
|
} catch {
|
|
setError('Failed to load templates');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [businessId]);
|
|
|
|
useEffect(() => {
|
|
loadTemplates();
|
|
}, [loadTemplates]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (highlightTimeoutRef.current) {
|
|
window.clearTimeout(highlightTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const targetEventSlug = searchParams.get('event');
|
|
if (!targetEventSlug || templates.length === 0) return;
|
|
if (handledFocusSlugRef.current === targetEventSlug) return;
|
|
|
|
const targetTemplate = templates.find(tmpl => tmpl.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;
|
|
|
|
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);
|
|
}, [activeTab, getTabForStatus, searchParams, templates]);
|
|
|
|
async function handleWhitelistSuccess() {
|
|
setWhitelistTarget(null);
|
|
await loadTemplates();
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
|
|
{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">
|
|
{error}
|
|
<button onClick={() => setError('')} className="text-error-text hover:text-red-900 font-bold">×</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>
|
|
|
|
{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>
|
|
</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>
|
|
</div>
|
|
) : (() => {
|
|
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>
|
|
</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>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 pt-2">
|
|
{!isBoundProfileMissing && tmpl.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"
|
|
>
|
|
Publish
|
|
</button>
|
|
)}
|
|
{!isBoundProfileMissing && tmpl.status === 'whitelisted' && (
|
|
<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"
|
|
>
|
|
<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>
|
|
</div>
|
|
);
|
|
})}
|
|
</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)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|