bolt-templates-sms-extensio.../client/src/components/TemplateDetailWorkspaceModa...

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>
);
}