353 lines
16 KiB
JavaScript
353 lines
16 KiB
JavaScript
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);
|
|
const executionMeta = template?.executionMeta || {};
|
|
const executionInputCount = Array.isArray(template?.requiredInputs) ? template.requiredInputs.length : 0;
|
|
const fallbackCount = previewState.fallbackPlaceholders.length;
|
|
const unresolvedCount = previewState.unresolvedPlaceholders.length;
|
|
|
|
return (
|
|
<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">
|
|
Deterministic 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>
|
|
{(fallbackCount > 0 || unresolvedCount > 0) && (
|
|
<p className={`text-xs font-medium ${unresolvedCount > 0 ? 'text-amber-700' : 'text-gray-500'}`}>
|
|
{unresolvedCount > 0
|
|
? `${unresolvedCount} placeholder${unresolvedCount === 1 ? '' : 's'} still need explicit mapping.`
|
|
: `${fallbackCount} placeholder${fallbackCount === 1 ? '' : 's'} used deterministic sample fallback values.`}
|
|
</p>
|
|
)}
|
|
</section>
|
|
</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">Render Strategy</label>
|
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
|
{executionMeta.renderStrategy === 'deterministic_sample_payload'
|
|
? 'Deterministic sample payload'
|
|
: 'Template variable mapping'}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-gray-500">Execution Inputs</label>
|
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
|
{executionInputCount} stored input{executionInputCount === 1 ? '' : 's'}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-gray-500">Template Variables</label>
|
|
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-base font-medium text-gray-800">
|
|
{Number.isFinite(executionMeta.placeholderCount) ? executionMeta.placeholderCount : 0} placeholder{executionMeta.placeholderCount === 1 ? '' : 's'}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<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>
|
|
);
|
|
}
|