1485 lines
63 KiB
JavaScript
1485 lines
63 KiB
JavaScript
import axios from 'axios';
|
||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
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;
|
||
const DLT_VARIABLE_OPTIONS = [
|
||
{ label: 'Generic Text', token: '{#var#}' },
|
||
{ label: 'Numeric', token: '{#numeric#}' },
|
||
{ label: 'URL', token: '{#url#}' },
|
||
{ label: 'URL OTT', token: '{#urlott#}' },
|
||
{ label: 'Callback Number', token: '{#cbn#}' },
|
||
{ label: 'Email', token: '{#email#}' },
|
||
{ label: 'Alphanumeric', token: '{#alphanumeric#}' },
|
||
];
|
||
const SUPPORTED_DLT_VARIABLE_OPTIONS = DLT_VARIABLE_OPTIONS;
|
||
const DLT_TOKEN_SET = new Set(SUPPORTED_DLT_VARIABLE_OPTIONS.map((option) => option.token));
|
||
const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
|
||
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
|
||
const ORDER_PAYMENT_EVENT_SLUGS = [
|
||
'placed',
|
||
'payment_failed',
|
||
];
|
||
const DELIVERY_EVENT_SLUGS = [
|
||
'out_for_delivery',
|
||
'delivery_attempt_failed',
|
||
'delivery_done',
|
||
];
|
||
const CANCELLATION_EVENT_SLUGS = [
|
||
'cancelled_at_dp',
|
||
'cancelled_customer',
|
||
'rejected_by_customer',
|
||
];
|
||
const RETURN_EVENT_SLUGS = [
|
||
'return_initiated',
|
||
'return_bag_picked',
|
||
'return_bag_delivered',
|
||
];
|
||
const REFUND_EVENT_SLUGS = [
|
||
'refund_completed',
|
||
'refund_failed',
|
||
'refund_initiated',
|
||
];
|
||
const CUSTOMER_EVENT_SECTIONS = [
|
||
{
|
||
id: 'order_payment',
|
||
label: 'Order & Payment',
|
||
description: 'Core order confirmation and critical payment updates customers genuinely care about.',
|
||
slugs: ORDER_PAYMENT_EVENT_SLUGS,
|
||
},
|
||
{
|
||
id: 'delivery',
|
||
label: 'Delivery Journey',
|
||
description: 'The moments that matter most once an order is close to the doorstep.',
|
||
slugs: DELIVERY_EVENT_SLUGS,
|
||
},
|
||
{
|
||
id: 'cancellations',
|
||
label: 'Cancellations & Rejections',
|
||
description: 'Critical order-stop events that customers should be notified about immediately.',
|
||
slugs: CANCELLATION_EVENT_SLUGS,
|
||
},
|
||
{
|
||
id: 'returns_refunds',
|
||
label: 'Returns & Refunds',
|
||
description: 'Only the key return and refund milestones worth notifying customers about.',
|
||
slugs: [...RETURN_EVENT_SLUGS, ...REFUND_EVENT_SLUGS],
|
||
},
|
||
{
|
||
id: 'custom',
|
||
label: 'Custom Events',
|
||
description: 'Business-specific events you added manually for your own messaging flows.',
|
||
slugs: [],
|
||
},
|
||
];
|
||
const CUSTOMER_EVENT_SECTION_BY_SLUG = new Map(
|
||
CUSTOMER_EVENT_SECTIONS.flatMap((section) => section.slugs.map((slug) => [slug, section.id])),
|
||
);
|
||
const CUSTOMER_EVENT_SECTION_ORDER = CUSTOMER_EVENT_SECTIONS.reduce((acc, section) => {
|
||
acc[section.id] = new Map(section.slugs.map((slug, index) => [slug, index]));
|
||
return acc;
|
||
}, {});
|
||
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
||
unselected: {
|
||
label: 'Not Selected',
|
||
badge: 'border-gray-200 bg-white text-gray-500',
|
||
dot: 'bg-gray-400',
|
||
},
|
||
pending_whitelisting: {
|
||
label: 'Pending Whitelisting',
|
||
badge: 'border-amber-200 bg-amber-50 text-amber-700',
|
||
dot: 'bg-amber-500',
|
||
},
|
||
whitelisted: {
|
||
label: 'Published',
|
||
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||
dot: 'bg-emerald-500',
|
||
},
|
||
};
|
||
|
||
function normalizeTemplateStatus(status) {
|
||
return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting';
|
||
}
|
||
|
||
function buildSelectedTemplatePreview(template = {}) {
|
||
const selectedTemplate = String(template?.selectedTemplate || '').trim();
|
||
if (!selectedTemplate) return null;
|
||
|
||
return {
|
||
eventSlug: String(template?.eventSlug || '').trim(),
|
||
selectedTemplate,
|
||
status: normalizeTemplateStatus(template?.status),
|
||
templateId: String(template?.templateId || '').trim(),
|
||
variableMap: template?.variableMap && typeof template.variableMap === 'object'
|
||
? template.variableMap
|
||
: {},
|
||
requiredInputs: Array.isArray(template?.requiredInputs) ? template.requiredInputs : [],
|
||
executionMeta: template?.executionMeta && typeof template.executionMeta === 'object'
|
||
? template.executionMeta
|
||
: {},
|
||
curlProfileId: String(template?.curlProfileId || '').trim(),
|
||
};
|
||
}
|
||
|
||
function getCustomerFacingSectionId(event) {
|
||
const slug = String(event?.slug || '');
|
||
|
||
if (!event?.isDefault) return 'custom';
|
||
return CUSTOMER_EVENT_SECTION_BY_SLUG.get(slug) || null;
|
||
}
|
||
|
||
function matchesEventSearch(event, searchTerm) {
|
||
const query = String(searchTerm || '').trim().toLowerCase();
|
||
if (!query) return true;
|
||
|
||
return [event?.label, event?.slug]
|
||
.filter(Boolean)
|
||
.some((value) => String(value).toLowerCase().includes(query));
|
||
}
|
||
|
||
function sortSectionEvents(sectionId, events) {
|
||
if (sectionId === 'custom') {
|
||
return [...events].sort((left, right) => String(left?.label || '').localeCompare(String(right?.label || '')));
|
||
}
|
||
|
||
const orderMap = CUSTOMER_EVENT_SECTION_ORDER[sectionId] || new Map();
|
||
return [...events].sort((left, right) => {
|
||
const leftRank = orderMap.get(String(left?.slug || '')) ?? Number.MAX_SAFE_INTEGER;
|
||
const rightRank = orderMap.get(String(right?.slug || '')) ?? Number.MAX_SAFE_INTEGER;
|
||
|
||
if (leftRank !== rightRank) return leftRank - rightRank;
|
||
return String(left?.label || '').localeCompare(String(right?.label || ''));
|
||
});
|
||
}
|
||
|
||
function buildVisibleEventSections(events, searchTerm) {
|
||
return CUSTOMER_EVENT_SECTIONS.map((section) => {
|
||
const filteredEvents = events.filter((event) => (
|
||
getCustomerFacingSectionId(event) === section.id
|
||
&& matchesEventSearch(event, searchTerm)
|
||
));
|
||
|
||
return {
|
||
...section,
|
||
events: sortSectionEvents(section.id, filteredEvents),
|
||
};
|
||
}).filter((section) => section.events.length > 0);
|
||
}
|
||
|
||
function getVariantKey(slug, index) {
|
||
return `${slug}:${index}`;
|
||
}
|
||
|
||
function getSelectedDraftKey(slug) {
|
||
return `${slug}:selected`;
|
||
}
|
||
|
||
function getDraftSuffix(draftKey = '') {
|
||
const separatorIndex = String(draftKey).lastIndexOf(':');
|
||
return separatorIndex === -1 ? '' : String(draftKey).slice(separatorIndex + 1);
|
||
}
|
||
|
||
function getVariantIndexFromDraftKey(draftKey = '') {
|
||
const suffix = getDraftSuffix(draftKey);
|
||
if (!suffix) return null;
|
||
if (suffix === 'selected') return null;
|
||
const index = Number(suffix);
|
||
return Number.isInteger(index) ? index : null;
|
||
}
|
||
|
||
function resolveDraftBaseText(slug, draftKey, variantsBySlug, selectedTemplateBySlug) {
|
||
if (!draftKey) return '';
|
||
const draftSuffix = getDraftSuffix(draftKey);
|
||
|
||
if (draftSuffix === 'selected') {
|
||
return String(selectedTemplateBySlug?.[slug]?.selectedTemplate || '');
|
||
}
|
||
|
||
const variantIndex = Number(draftSuffix);
|
||
if (!Number.isInteger(variantIndex)) return '';
|
||
return String(variantsBySlug?.[slug]?.[variantIndex] || '');
|
||
}
|
||
|
||
function createVariantDraft(text = '') {
|
||
return {
|
||
originalText: text,
|
||
currentText: text,
|
||
validationStatus: 'idle',
|
||
why: '',
|
||
issues: [],
|
||
lastCheckedText: '',
|
||
};
|
||
}
|
||
|
||
function getDltTokens(text = '') {
|
||
return String(text).match(DLT_TOKEN_REGEX) || [];
|
||
}
|
||
|
||
function countDltTokens(text = '') {
|
||
return getDltTokens(text).length;
|
||
}
|
||
|
||
function getInvalidDltTokens(text = '') {
|
||
return (String(text).match(DLT_TOKEN_LIKE_REGEX) || []).filter((token) => !DLT_TOKEN_SET.has(token));
|
||
}
|
||
|
||
function hasMalformedDltFragments(text = '') {
|
||
const strippedText = String(text).replace(DLT_TOKEN_LIKE_REGEX, '');
|
||
return strippedText.includes('{#') || strippedText.includes('#}');
|
||
}
|
||
|
||
function buildDraftsForVariants(slug, generatedVariants = []) {
|
||
const nextDrafts = {};
|
||
generatedVariants.forEach((variant, index) => {
|
||
nextDrafts[getVariantKey(slug, index)] = createVariantDraft(variant);
|
||
});
|
||
return nextDrafts;
|
||
}
|
||
|
||
function removeDraftsForSlug(drafts, slug) {
|
||
const nextDrafts = { ...drafts };
|
||
Object.keys(nextDrafts).forEach((key) => {
|
||
if (key.startsWith(`${slug}:`)) delete nextDrafts[key];
|
||
});
|
||
return nextDrafts;
|
||
}
|
||
|
||
function syncDraftsWithVariants(existingDrafts, variantsBySlug) {
|
||
const nextDrafts = {};
|
||
|
||
Object.entries(variantsBySlug).forEach(([slug, generatedVariants]) => {
|
||
generatedVariants.forEach((variant, index) => {
|
||
const key = getVariantKey(slug, index);
|
||
const existing = existingDrafts[key];
|
||
nextDrafts[key] = existing && existing.originalText === variant
|
||
? existing
|
||
: createVariantDraft(variant);
|
||
});
|
||
});
|
||
|
||
return nextDrafts;
|
||
}
|
||
|
||
function buildTemplateUiState(templates = []) {
|
||
const nextVariants = {};
|
||
const nextGenState = {};
|
||
const nextTemplateStatusBySlug = {};
|
||
const nextSelectedTemplateBySlug = {};
|
||
|
||
templates.forEach((template) => {
|
||
if (!template?.eventSlug) return;
|
||
|
||
if (template.selectedTemplate) {
|
||
const normalizedStatus = normalizeTemplateStatus(template.status);
|
||
nextTemplateStatusBySlug[template.eventSlug] = normalizedStatus;
|
||
nextGenState[template.eventSlug] = 'selected';
|
||
nextSelectedTemplateBySlug[template.eventSlug] = buildSelectedTemplatePreview(template);
|
||
}
|
||
|
||
if (Array.isArray(template.generatedVariants) && template.generatedVariants.length > 0) {
|
||
nextVariants[template.eventSlug] = template.generatedVariants;
|
||
if (!template.selectedTemplate) nextGenState[template.eventSlug] = 'done';
|
||
}
|
||
});
|
||
|
||
return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug };
|
||
}
|
||
|
||
function TemplateGenerationWorkspaceModal({
|
||
eventSlug,
|
||
eventLabel,
|
||
statusConfig,
|
||
loading,
|
||
generatedVariants,
|
||
selectedTemplatePreview,
|
||
activeDraftKey,
|
||
activeDraft,
|
||
openVariableMenuKey,
|
||
onClose,
|
||
onChooseVariant,
|
||
onDraftChange,
|
||
onTrackSelection,
|
||
onToggleVariableMenu,
|
||
onInsertVariable,
|
||
onRevert,
|
||
onCheck,
|
||
onSelect,
|
||
onRegenerate,
|
||
setVariableMenuRef,
|
||
setTextareaRef,
|
||
workspaceError,
|
||
selectingDraftKey,
|
||
showClosePrompt,
|
||
closePromptTitle,
|
||
closePromptDescription,
|
||
discardingWorkspace,
|
||
onKeepWorkspace,
|
||
onDiscardWorkspace,
|
||
}) {
|
||
const currentText = activeDraft?.currentText || '';
|
||
const originalText = activeDraft?.originalText || '';
|
||
const validationStatus = activeDraft?.validationStatus || 'idle';
|
||
const currentMatchesCheckedText = activeDraft?.lastCheckedText === currentText;
|
||
const isEdited = currentText !== originalText;
|
||
const dltTokenCount = countDltTokens(currentText);
|
||
const invalidDltTokens = getInvalidDltTokens(currentText);
|
||
const hasMalformedDltToken = hasMalformedDltFragments(currentText);
|
||
const hasInvalidPlaceholder = invalidDltTokens.length > 0 || hasMalformedDltToken;
|
||
const tooLong = currentText.length > MAX_SMS_LENGTH;
|
||
const hasChosenDraft = !!activeDraftKey;
|
||
const canRunCheck = hasChosenDraft && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking';
|
||
const failedCurrentCheck = validationStatus === 'rejected' && currentMatchesCheckedText;
|
||
const canSelectDraft = hasChosenDraft
|
||
&& !tooLong
|
||
&& !hasInvalidPlaceholder
|
||
&& !failedCurrentCheck
|
||
&& (!isEdited || (validationStatus === 'approved' && currentMatchesCheckedText));
|
||
const canInsertVariable = hasChosenDraft;
|
||
const activeVariantIndex = getVariantIndexFromDraftKey(activeDraftKey);
|
||
|
||
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: '980px', maxWidth: 'calc(100vw - 2rem)', height: 'min(84vh, 820px)' }}
|
||
>
|
||
<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">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h2 className="text-2xl font-semibold tracking-tight text-gray-900">{eventLabel}</h2>
|
||
{statusConfig && (
|
||
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold ${statusConfig.badge}`}>
|
||
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
|
||
{statusConfig.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</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>
|
||
|
||
{loading ? (
|
||
<div className="flex min-h-0 flex-1 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="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||
<div className="space-y-5 pb-2">
|
||
{workspaceError && (
|
||
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700">
|
||
{workspaceError}
|
||
</div>
|
||
)}
|
||
|
||
{generatedVariants.length > 0 ? (
|
||
<section className="space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Variants</p>
|
||
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||
{generatedVariants.length} option{generatedVariants.length === 1 ? '' : 's'}
|
||
</span>
|
||
</div>
|
||
<div className="grid gap-3 lg:grid-cols-3">
|
||
{generatedVariants.map((variant, index) => {
|
||
const variantKey = getVariantKey(eventSlug, index);
|
||
const isActive = activeVariantIndex === index;
|
||
return (
|
||
<button
|
||
key={variantKey}
|
||
type="button"
|
||
onClick={() => onChooseVariant(index)}
|
||
className={`flex min-h-[150px] flex-col rounded-xl border px-4 py-4 text-left transition ${isActive
|
||
? 'border-primary-blue bg-indigo-50/40 shadow-sm'
|
||
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">
|
||
Variant {index + 1}
|
||
</span>
|
||
{isActive && (
|
||
<span className="rounded-full border border-indigo-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-primary-dark">
|
||
In editor
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-3 line-clamp-5 text-sm leading-relaxed text-gray-700 break-words">
|
||
{variant}
|
||
</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
) : selectedTemplatePreview ? (
|
||
<section className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{selectedTemplatePreview.templateId ? (
|
||
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
|
||
Template ID {selectedTemplatePreview.templateId}
|
||
</span>
|
||
) : (
|
||
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
|
||
Template ID pending
|
||
</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="rounded-xl border border-gray-200 bg-white">
|
||
{hasChosenDraft ? (
|
||
<div className="space-y-4 p-5">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
|
||
{getDraftSuffix(activeDraftKey) === 'selected' ? 'Current Template' : `Variant ${activeVariantIndex + 1}`}
|
||
</span>
|
||
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
|
||
{isEdited ? 'Edited Draft' : 'Working Draft'}
|
||
</span>
|
||
{validationStatus === 'checking' && (
|
||
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
|
||
Checking
|
||
</span>
|
||
)}
|
||
{validationStatus === 'approved' && currentMatchesCheckedText && (
|
||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold text-emerald-700">
|
||
Passed
|
||
</span>
|
||
)}
|
||
{validationStatus === 'rejected' && currentMatchesCheckedText && (
|
||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] font-semibold text-amber-700">
|
||
Needs changes
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className="relative"
|
||
ref={setVariableMenuRef}
|
||
>
|
||
<button
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={onToggleVariableMenu}
|
||
disabled={!canInsertVariable}
|
||
className="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-primary-dark transition hover:bg-gray-50 hover:border-gray-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
# Add Variable
|
||
</button>
|
||
|
||
{openVariableMenuKey === activeDraftKey && (
|
||
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||
<div className="border-b border-gray-100 px-4 py-2">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Insert variable</p>
|
||
</div>
|
||
<div className="py-1">
|
||
{DLT_VARIABLE_OPTIONS.map((option) => (
|
||
<button
|
||
key={option.token}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => onInsertVariable(option.token)}
|
||
className="flex w-full items-center justify-between gap-3 px-4 py-2 text-left transition hover:bg-gray-50"
|
||
>
|
||
<span className="text-sm font-semibold text-gray-800">{option.label}</span>
|
||
<span className="text-xs font-mono text-primary-dark">{option.token}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<textarea
|
||
ref={setTextareaRef}
|
||
value={currentText}
|
||
onChange={(e) => onDraftChange(e.target.value)}
|
||
onFocus={(e) => onTrackSelection(e.target)}
|
||
onClick={(e) => onTrackSelection(e.target)}
|
||
onSelect={(e) => onTrackSelection(e.target)}
|
||
onKeyUp={(e) => onTrackSelection(e.target)}
|
||
rows={6}
|
||
className={`w-full resize-y rounded-lg border px-4 py-3 font-mono text-sm leading-relaxed text-gray-800 focus:outline-none focus:ring-2 ${isEdited
|
||
? 'border-gray-200 bg-white focus:border-amber-300 focus:ring-amber-200'
|
||
: 'border-gray-200 bg-white focus:border-primary-blue focus:ring-indigo-100'
|
||
}`}
|
||
/>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${tooLong
|
||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||
: 'border-gray-200 bg-gray-50 text-gray-600'
|
||
}`}>
|
||
{currentText.length} / {MAX_SMS_LENGTH}
|
||
</span>
|
||
<span className="rounded-md border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-primary-dark">
|
||
DLT vars: {dltTokenCount}
|
||
</span>
|
||
</div>
|
||
|
||
{isEdited && (
|
||
<button
|
||
type="button"
|
||
onClick={onRevert}
|
||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50 hover:border-gray-400"
|
||
>
|
||
Revert to original
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isEdited && (
|
||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Original generated version</p>
|
||
<p className="mt-2 whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-gray-600">{originalText}</p>
|
||
</div>
|
||
)}
|
||
|
||
{invalidDltTokens.length > 0 && (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||
Unsupported variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>
|
||
</div>
|
||
)}
|
||
|
||
{hasMalformedDltToken && invalidDltTokens.length === 0 && (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||
Finish or remove incomplete variable text before checking or selecting this draft.
|
||
</div>
|
||
)}
|
||
|
||
{tooLong && (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||
Shorten this template to {MAX_SMS_LENGTH} characters or less before continuing.
|
||
</div>
|
||
)}
|
||
|
||
{validationStatus === 'rejected' && currentMatchesCheckedText && (activeDraft?.issues?.length > 0 || activeDraft?.why) && (
|
||
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
|
||
<p className="font-semibold">Why it did not pass:</p>
|
||
{activeDraft?.issues?.length > 0 ? (
|
||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||
{activeDraft.issues.map((issue, index) => (
|
||
<li key={`${issue.code || 'issue'}-${index}`}>
|
||
{issue.message}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="mt-2">{activeDraft.why}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="px-5 py-12 text-center text-sm font-medium text-gray-500">
|
||
Choose a generated variant to begin.
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 flex flex-wrap items-center justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||
<button
|
||
type="button"
|
||
onClick={onRegenerate}
|
||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 hover:border-gray-400"
|
||
>
|
||
Regenerate
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onCheck}
|
||
disabled={!canRunCheck}
|
||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 hover:border-gray-400 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{validationStatus === 'checking' ? 'Checking…' : 'Check again'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onSelect}
|
||
disabled={!canSelectDraft || selectingDraftKey === activeDraftKey}
|
||
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
Select
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{showClosePrompt && (
|
||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/70 px-4 backdrop-blur-[1px]">
|
||
<div className="w-full max-w-sm rounded-2xl border border-gray-200 bg-white p-5 shadow-xl">
|
||
<div className="space-y-2">
|
||
<h3 className="text-lg font-semibold tracking-tight text-gray-900">{closePromptTitle}</h3>
|
||
<p className="text-sm leading-relaxed text-gray-500">{closePromptDescription}</p>
|
||
</div>
|
||
<div className="mt-5 flex items-center justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onDiscardWorkspace}
|
||
disabled={discardingWorkspace}
|
||
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{discardingWorkspace ? 'Discarding…' : 'Discard'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onKeepWorkspace}
|
||
disabled={discardingWorkspace}
|
||
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
Keep
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Events() {
|
||
const { businessId } = useParams();
|
||
const { refreshOnboardingState } = useBusiness();
|
||
const [events, setEvents] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [newLabel, setNewLabel] = useState('');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [addingEvent, setAddingEvent] = useState(false);
|
||
const [showAddForm, setShowAddForm] = useState(false);
|
||
const [genState, setGenState] = useState({});
|
||
const [variants, setVariants] = useState({});
|
||
const [variantDrafts, setVariantDrafts] = useState({});
|
||
const [activeDraftKeyBySlug, setActiveDraftKeyBySlug] = useState({});
|
||
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
||
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
|
||
const [, setActiveCaretVariantKey] = useState('');
|
||
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);
|
||
const [error, setError] = useState('');
|
||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||
|
||
const textareaRefs = useRef({});
|
||
const selectionStateRef = useRef({});
|
||
const variableMenuRefs = useRef({});
|
||
const generationControllersRef = useRef({});
|
||
const generationRequestRef = useRef({});
|
||
const templateWorkspaceRef = useRef({ slug: '', sessionId: 0 });
|
||
|
||
useEffect(() => {
|
||
function handlePointerDown(event) {
|
||
if (!openVariableMenuKey) return;
|
||
const activeMenu = variableMenuRefs.current[openVariableMenuKey];
|
||
if (activeMenu && !activeMenu.contains(event.target)) {
|
||
setOpenVariableMenuKey('');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('mousedown', handlePointerDown);
|
||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||
}, [openVariableMenuKey]);
|
||
|
||
useEffect(() => {
|
||
templateWorkspaceRef.current = templateWorkspace;
|
||
}, [templateWorkspace]);
|
||
|
||
useEffect(() => () => {
|
||
Object.values(generationControllersRef.current).forEach((controller) => {
|
||
controller?.abort?.();
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!templateWorkspace.slug) 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;
|
||
};
|
||
}, [templateWorkspace.slug]);
|
||
|
||
const loadEvents = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
|
||
apiClient.get(`/api/businesses/${businessId}/events`),
|
||
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
|
||
apiClient.get(`/api/businesses/${businessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
||
]);
|
||
|
||
const templates = templatesRes.data.templates || [];
|
||
const {
|
||
nextVariants,
|
||
nextGenState,
|
||
nextTemplateStatusBySlug,
|
||
nextSelectedTemplateBySlug,
|
||
} = buildTemplateUiState(templates);
|
||
|
||
setEvents(eventsRes.data.events || []);
|
||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.hasStoredCurl);
|
||
setVariants(nextVariants);
|
||
setGenState(nextGenState);
|
||
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
|
||
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||
} catch {
|
||
setError('Failed to load events');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [businessId]);
|
||
|
||
useEffect(() => {
|
||
loadEvents();
|
||
}, [loadEvents]);
|
||
|
||
async function handleAddEvent(e) {
|
||
e.preventDefault();
|
||
if (!newLabel.trim()) return;
|
||
setAddingEvent(true);
|
||
setError('');
|
||
try {
|
||
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
|
||
setNewLabel('');
|
||
setShowAddForm(false);
|
||
await loadEvents();
|
||
} catch (err) {
|
||
setError(err.response?.data?.error || 'Failed to add event');
|
||
} finally {
|
||
setAddingEvent(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(slug) {
|
||
try {
|
||
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
|
||
await loadEvents();
|
||
} catch (err) {
|
||
setError(err.response?.data?.error || 'Failed to delete event');
|
||
}
|
||
}
|
||
|
||
function clearWorkspaceInteractionState() {
|
||
setOpenVariableMenuKey('');
|
||
setActiveCaretVariantKey('');
|
||
}
|
||
|
||
function closeTemplateWorkspace() {
|
||
clearWorkspaceInteractionState();
|
||
setShowClosePrompt(false);
|
||
setDiscardingWorkspace(false);
|
||
setWorkspaceError('');
|
||
setTemplateWorkspace({ slug: '', sessionId: 0 });
|
||
}
|
||
|
||
function closeTemplateViewer() {
|
||
setTemplateViewerSlug('');
|
||
}
|
||
|
||
function getWorkspaceBaseState(slug) {
|
||
if (selectedTemplateBySlug[slug]?.selectedTemplate) return 'selected';
|
||
if ((variants[slug] || []).length > 0) return 'done';
|
||
return 'idle';
|
||
}
|
||
|
||
function openTemplateWorkspace(slug) {
|
||
const nextSessionId = Date.now();
|
||
const eventVariants = variants[slug] || [];
|
||
const selectedTemplatePreview = selectedTemplateBySlug[slug];
|
||
|
||
if (eventVariants.length === 0 && selectedTemplatePreview?.selectedTemplate) {
|
||
const selectedDraftKey = getSelectedDraftKey(slug);
|
||
setVariantDrafts((currentDrafts) => {
|
||
const existingDraft = currentDrafts[selectedDraftKey];
|
||
if (existingDraft && existingDraft.originalText === selectedTemplatePreview.selectedTemplate) {
|
||
return currentDrafts;
|
||
}
|
||
return {
|
||
...currentDrafts,
|
||
[selectedDraftKey]: createVariantDraft(selectedTemplatePreview.selectedTemplate),
|
||
};
|
||
});
|
||
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: selectedDraftKey }));
|
||
}
|
||
|
||
clearWorkspaceInteractionState();
|
||
setShowClosePrompt(false);
|
||
setDiscardingWorkspace(false);
|
||
setWorkspaceError('');
|
||
setTemplateWorkspace({ slug, sessionId: nextSessionId });
|
||
return nextSessionId;
|
||
}
|
||
|
||
function resetDraftToOriginal(slug, draftKey) {
|
||
if (!draftKey) return;
|
||
|
||
setVariantDrafts((currentDrafts) => {
|
||
const existingDraft = currentDrafts[draftKey];
|
||
const originalText = existingDraft?.originalText || resolveDraftBaseText(slug, draftKey, variants, selectedTemplateBySlug);
|
||
return {
|
||
...currentDrafts,
|
||
[draftKey]: createVariantDraft(originalText),
|
||
};
|
||
});
|
||
}
|
||
|
||
function requestCloseTemplateWorkspace() {
|
||
const { slug } = templateWorkspaceRef.current;
|
||
if (!slug) return;
|
||
|
||
const isLoading = genState[slug] === 'loading';
|
||
const hasGeneratedVariants = (variants[slug] || []).length > 0;
|
||
const activeDraftKey = activeDraftKeyBySlug[slug] || '';
|
||
const activeDraft = activeDraftKey ? variantDrafts[activeDraftKey] : null;
|
||
const hasUnsavedChanges = !!activeDraft && activeDraft.currentText !== activeDraft.originalText;
|
||
|
||
if (isLoading) {
|
||
generationControllersRef.current[slug]?.abort();
|
||
delete generationControllersRef.current[slug];
|
||
const activeRequest = generationRequestRef.current[slug];
|
||
delete generationRequestRef.current[slug];
|
||
setGenState((currentState) => ({
|
||
...currentState,
|
||
[slug]: activeRequest?.previousState || getWorkspaceBaseState(slug),
|
||
}));
|
||
closeTemplateWorkspace();
|
||
return;
|
||
}
|
||
|
||
if (!hasGeneratedVariants && !hasUnsavedChanges) {
|
||
closeTemplateWorkspace();
|
||
return;
|
||
}
|
||
|
||
setShowClosePrompt(true);
|
||
}
|
||
|
||
function keepTemplateWorkspace() {
|
||
closeTemplateWorkspace();
|
||
}
|
||
|
||
async function discardTemplateWorkspace() {
|
||
const { slug } = templateWorkspaceRef.current;
|
||
if (!slug) return;
|
||
|
||
const hasGeneratedVariants = (variants[slug] || []).length > 0;
|
||
const activeDraftKey = activeDraftKeyBySlug[slug] || '';
|
||
const activeDraft = activeDraftKey ? variantDrafts[activeDraftKey] : null;
|
||
const hasUnsavedChanges = !!activeDraft && activeDraft.currentText !== activeDraft.originalText;
|
||
|
||
setDiscardingWorkspace(true);
|
||
setWorkspaceError('');
|
||
|
||
try {
|
||
if (hasGeneratedVariants) {
|
||
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/discard`);
|
||
const preservedTemplatePreview = buildSelectedTemplatePreview(res.data?.template);
|
||
const preservedStatus = preservedTemplatePreview?.status || '';
|
||
|
||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
||
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
||
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: '' }));
|
||
setGenState((currentState) => ({
|
||
...currentState,
|
||
[slug]: preservedTemplatePreview ? 'selected' : 'idle',
|
||
}));
|
||
setSelectedTemplateBySlug((currentTemplates) => {
|
||
const nextTemplates = { ...currentTemplates };
|
||
if (preservedTemplatePreview) nextTemplates[slug] = preservedTemplatePreview;
|
||
else delete nextTemplates[slug];
|
||
return nextTemplates;
|
||
});
|
||
setTemplateStatusBySlug((currentStatuses) => {
|
||
const nextStatuses = { ...currentStatuses };
|
||
if (preservedTemplatePreview) nextStatuses[slug] = preservedStatus;
|
||
else delete nextStatuses[slug];
|
||
return nextStatuses;
|
||
});
|
||
} else if (hasUnsavedChanges) {
|
||
resetDraftToOriginal(slug, activeDraftKey);
|
||
}
|
||
|
||
closeTemplateWorkspace();
|
||
} catch (err) {
|
||
setWorkspaceError(err.response?.data?.error || 'Failed to discard template workspace');
|
||
setDiscardingWorkspace(false);
|
||
}
|
||
}
|
||
|
||
async function handleGenerate(slug, { sessionId } = {}) {
|
||
if (!readyToGenerate) {
|
||
setError('Configure and activate a cURL profile before generating templates.');
|
||
return;
|
||
}
|
||
|
||
const requestId = `${Date.now()}-${Math.random()}`;
|
||
const controller = new AbortController();
|
||
const previousState = getWorkspaceBaseState(slug);
|
||
|
||
generationControllersRef.current[slug]?.abort();
|
||
generationControllersRef.current[slug] = controller;
|
||
generationRequestRef.current[slug] = { requestId, sessionId, previousState };
|
||
setGenState((state) => ({ ...state, [slug]: 'loading' }));
|
||
setError('');
|
||
setWorkspaceError('');
|
||
setShowClosePrompt(false);
|
||
setDiscardingWorkspace(false);
|
||
|
||
try {
|
||
const res = await apiClient.post(
|
||
`/api/businesses/${businessId}/events/${slug}/generate`,
|
||
{},
|
||
{ signal: controller.signal }
|
||
);
|
||
const generatedVariants = res.data.variants || [];
|
||
|
||
if (generationRequestRef.current[slug]?.requestId !== requestId) return;
|
||
if (sessionId && (
|
||
templateWorkspaceRef.current.slug !== slug
|
||
|| templateWorkspaceRef.current.sessionId !== sessionId
|
||
)) {
|
||
return;
|
||
}
|
||
|
||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: generatedVariants }));
|
||
setVariantDrafts((currentDrafts) => ({
|
||
...removeDraftsForSlug(currentDrafts, slug),
|
||
...buildDraftsForVariants(slug, generatedVariants),
|
||
}));
|
||
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: '' }));
|
||
setOpenVariableMenuKey('');
|
||
setActiveCaretVariantKey('');
|
||
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
||
} catch (err) {
|
||
if (axios.isCancel(err) || err.code === 'ERR_CANCELED') return;
|
||
if (generationRequestRef.current[slug]?.requestId !== requestId) return;
|
||
if (sessionId && (
|
||
templateWorkspaceRef.current.slug !== slug
|
||
|| templateWorkspaceRef.current.sessionId !== sessionId
|
||
)) {
|
||
return;
|
||
}
|
||
|
||
setWorkspaceError(err.response?.data?.error || 'Generation failed');
|
||
setGenState((state) => ({ ...state, [slug]: 'error' }));
|
||
} finally {
|
||
if (generationRequestRef.current[slug]?.requestId === requestId) {
|
||
delete generationControllersRef.current[slug];
|
||
delete generationRequestRef.current[slug];
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleOpenTemplateWorkspace(slug) {
|
||
openTemplateWorkspace(slug);
|
||
}
|
||
|
||
function handleOpenTemplateViewer(slug) {
|
||
setTemplateViewerSlug(slug);
|
||
}
|
||
|
||
function handleOpenGenerateWorkspace(slug) {
|
||
const sessionId = openTemplateWorkspace(slug);
|
||
handleGenerate(slug, { sessionId });
|
||
}
|
||
|
||
function handleChooseDraft(slug, draftKey) {
|
||
const currentDraftKey = activeDraftKeyBySlug[slug] || '';
|
||
const currentDraft = currentDraftKey ? variantDrafts[currentDraftKey] : null;
|
||
const hasUnsavedChanges = currentDraftKey
|
||
&& currentDraftKey !== draftKey
|
||
&& currentDraft
|
||
&& currentDraft.currentText !== currentDraft.originalText;
|
||
|
||
if (hasUnsavedChanges && !window.confirm('Discard changes to this draft?')) {
|
||
return;
|
||
}
|
||
|
||
if (hasUnsavedChanges) {
|
||
resetDraftToOriginal(slug, currentDraftKey);
|
||
}
|
||
|
||
clearWorkspaceInteractionState();
|
||
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: draftKey }));
|
||
setWorkspaceError('');
|
||
}
|
||
|
||
async function handleValidateEdit(slug, draftKey) {
|
||
const draft = variantDrafts[draftKey];
|
||
const editedTemplate = draft?.currentText || '';
|
||
|
||
if (!editedTemplate) return;
|
||
|
||
setVariantDrafts((currentDrafts) => ({
|
||
...currentDrafts,
|
||
[draftKey]: {
|
||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||
validationStatus: 'checking',
|
||
why: '',
|
||
issues: [],
|
||
lastCheckedText: '',
|
||
},
|
||
}));
|
||
setWorkspaceError('');
|
||
|
||
try {
|
||
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/validate-edit`, {
|
||
editedTemplate,
|
||
});
|
||
|
||
const issues = Array.isArray(res.data?.issues)
|
||
? res.data.issues
|
||
.filter((issue) => issue && typeof issue === 'object')
|
||
.map((issue) => ({
|
||
code: String(issue.code || '').trim(),
|
||
message: String(issue.message || '').trim(),
|
||
evidence: String(issue.evidence || '').trim(),
|
||
}))
|
||
.filter((issue) => issue.message)
|
||
: [];
|
||
|
||
setVariantDrafts((currentDrafts) => ({
|
||
...currentDrafts,
|
||
[draftKey]: {
|
||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
||
why: String(res.data?.why || issues[0]?.message || ''),
|
||
issues,
|
||
lastCheckedText: editedTemplate,
|
||
},
|
||
}));
|
||
} catch (err) {
|
||
setWorkspaceError(err.response?.data?.error || 'Failed to validate edited template');
|
||
setVariantDrafts((currentDrafts) => ({
|
||
...currentDrafts,
|
||
[draftKey]: {
|
||
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
|
||
validationStatus: 'idle',
|
||
why: '',
|
||
issues: [],
|
||
lastCheckedText: '',
|
||
},
|
||
}));
|
||
}
|
||
}
|
||
|
||
async function handleSelect(slug, draftKey) {
|
||
const draft = variantDrafts[draftKey];
|
||
const selectedVariant = draft?.currentText || '';
|
||
|
||
if (!selectedVariant) return;
|
||
|
||
setSelectingVariantKey(draftKey);
|
||
setWorkspaceError('');
|
||
|
||
try {
|
||
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant });
|
||
const selectedTemplatePreview = buildSelectedTemplatePreview(res.data);
|
||
await refreshOnboardingState(businessId).catch(() => null);
|
||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
||
setVariantDrafts((currentDrafts) => ({
|
||
...removeDraftsForSlug(currentDrafts, slug),
|
||
[getSelectedDraftKey(slug)]: createVariantDraft(selectedTemplatePreview?.selectedTemplate || selectedVariant),
|
||
}));
|
||
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: getSelectedDraftKey(slug) }));
|
||
setOpenVariableMenuKey('');
|
||
setActiveCaretVariantKey('');
|
||
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
||
setTemplateStatusBySlug((currentStatuses) => ({
|
||
...currentStatuses,
|
||
[slug]: normalizeTemplateStatus(res.data?.status),
|
||
}));
|
||
setSelectedTemplateBySlug((currentTemplates) => ({
|
||
...currentTemplates,
|
||
[slug]: selectedTemplatePreview,
|
||
}));
|
||
closeTemplateWorkspace();
|
||
} catch (err) {
|
||
setWorkspaceError(err.response?.data?.error || 'Failed to select template');
|
||
} finally {
|
||
setSelectingVariantKey('');
|
||
}
|
||
}
|
||
|
||
function handleVariantChange(slug, draftKey, nextText) {
|
||
const originalText = variantDrafts[draftKey]?.originalText || resolveDraftBaseText(slug, draftKey, variants, selectedTemplateBySlug);
|
||
|
||
setVariantDrafts((currentDrafts) => ({
|
||
...currentDrafts,
|
||
[draftKey]: {
|
||
originalText,
|
||
currentText: nextText,
|
||
validationStatus: 'idle',
|
||
why: '',
|
||
issues: [],
|
||
lastCheckedText: '',
|
||
},
|
||
}));
|
||
}
|
||
|
||
function handleRevertVariant(slug, draftKey) {
|
||
resetDraftToOriginal(slug, draftKey);
|
||
setOpenVariableMenuKey('');
|
||
}
|
||
|
||
function trackTextareaSelection(draftKey, target) {
|
||
selectionStateRef.current[draftKey] = {
|
||
start: target.selectionStart ?? 0,
|
||
end: target.selectionEnd ?? 0,
|
||
};
|
||
setActiveCaretVariantKey(draftKey);
|
||
}
|
||
|
||
function handleVariableMenuToggle(draftKey) {
|
||
setOpenVariableMenuKey((currentKey) => currentKey === draftKey ? '' : draftKey);
|
||
}
|
||
|
||
function insertVariableToken(slug, draftKey, token) {
|
||
const draft = variantDrafts[draftKey] || createVariantDraft(resolveDraftBaseText(slug, draftKey, variants, selectedTemplateBySlug));
|
||
const textarea = textareaRefs.current[draftKey];
|
||
const selection = selectionStateRef.current[draftKey];
|
||
|
||
if (!textarea || !selection) return;
|
||
|
||
const start = selection.start ?? 0;
|
||
const end = selection.end ?? start;
|
||
const nextText = `${draft.currentText.slice(0, start)}${token}${draft.currentText.slice(end)}`;
|
||
|
||
handleVariantChange(slug, draftKey, nextText);
|
||
setOpenVariableMenuKey('');
|
||
|
||
requestAnimationFrame(() => {
|
||
const nextCaretPosition = start + token.length;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(nextCaretPosition, nextCaretPosition);
|
||
selectionStateRef.current[draftKey] = {
|
||
start: nextCaretPosition,
|
||
end: nextCaretPosition,
|
||
};
|
||
setActiveCaretVariantKey(draftKey);
|
||
});
|
||
}
|
||
|
||
function handleRegenerate(slug) {
|
||
const activeDraftKey = activeDraftKeyBySlug[slug] || '';
|
||
const activeDraft = activeDraftKey ? variantDrafts[activeDraftKey] : null;
|
||
const hasUnsavedChanges = !!activeDraft && activeDraft.currentText !== activeDraft.originalText;
|
||
|
||
if (hasUnsavedChanges && !window.confirm('Discard this draft and generate new options?')) {
|
||
return;
|
||
}
|
||
|
||
const sessionId = templateWorkspaceRef.current.slug === slug
|
||
? templateWorkspaceRef.current.sessionId
|
||
: openTemplateWorkspace(slug);
|
||
|
||
handleGenerate(slug, { sessionId });
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
const eventSections = buildVisibleEventSections(events, searchTerm);
|
||
const totalVisibleEvents = eventSections.reduce((count, section) => count + section.events.length, 0);
|
||
const workspaceSlug = templateWorkspace.slug;
|
||
const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null;
|
||
const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : [];
|
||
const workspaceSelectedTemplatePreview = workspaceSlug ? (selectedTemplateBySlug[workspaceSlug] || null) : null;
|
||
const workspaceTemplateStatus = workspaceSlug ? (templateStatusBySlug[workspaceSlug] || 'unselected') : 'unselected';
|
||
const workspaceStatusConfig = EVENT_TEMPLATE_STATUS_CONFIG[workspaceTemplateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
||
const workspaceActiveDraftKey = workspaceSlug ? (activeDraftKeyBySlug[workspaceSlug] || '') : '';
|
||
const workspaceDraft = workspaceSlug && workspaceActiveDraftKey
|
||
? (variantDrafts[workspaceActiveDraftKey] || createVariantDraft(resolveDraftBaseText(workspaceSlug, workspaceActiveDraftKey, variants, selectedTemplateBySlug)))
|
||
: null;
|
||
const workspaceHasGeneratedVariants = workspaceVariants.length > 0;
|
||
const workspaceHasUnsavedChanges = !!workspaceDraft && workspaceDraft.currentText !== workspaceDraft.originalText;
|
||
const workspaceClosePromptTitle = workspaceHasGeneratedVariants
|
||
? 'Keep these templates?'
|
||
: 'Keep this draft?';
|
||
const workspaceClosePromptDescription = workspaceHasGeneratedVariants
|
||
? (workspaceHasUnsavedChanges
|
||
? 'Keep closes this workspace and leaves the generated options and your draft here for later. Discard removes these options and your unsaved edits.'
|
||
: 'Keep closes this workspace and leaves the generated options here for later. Discard removes them.')
|
||
: 'Keep closes this workspace and preserves your draft. Discard removes your unsaved edits.';
|
||
|
||
return (
|
||
<>
|
||
<div className="max-w-4xl mx-auto">
|
||
<div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-800 tracking-tight">Events</h1>
|
||
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for customer-facing lifecycle events.</p>
|
||
</div>
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="relative flex-1 sm:max-w-md">
|
||
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-gray-400">
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m21 21-4.35-4.35m1.85-5.15a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" />
|
||
</svg>
|
||
</span>
|
||
<input
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
placeholder="Search events"
|
||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-11 pr-10 text-sm font-medium text-gray-800 placeholder-gray-400 transition focus:border-primary-blue focus:outline-none focus:ring-2 focus:ring-indigo-100"
|
||
/>
|
||
{searchTerm && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchTerm('')}
|
||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-400 hover:text-gray-600"
|
||
aria-label="Clear search"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18 18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-primary-dark">
|
||
{totalVisibleEvents} visible
|
||
</span>
|
||
<button
|
||
onClick={() => setShowAddForm((visible) => !visible)}
|
||
className="px-4 py-2 rounded-lg bg-white border border-gray-300 text-sm text-gray-700 font-semibold hover:bg-gray-50 hover:border-gray-400 transition"
|
||
>
|
||
{showAddForm ? 'Cancel' : '+ Add Event'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{!readyToGenerate && (
|
||
<div className="mb-6 px-4 py-2 rounded-lg bg-white border border-gray-200 text-amber-800 text-sm font-medium flex items-center gap-2">
|
||
<span>⚠️</span>
|
||
<span>Set up and activate a <strong>cURL profile</strong> before generating templates.</span>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="mb-6 px-4 py-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
|
||
{error}
|
||
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{showAddForm && (
|
||
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-lg bg-white border border-gray-200 ">
|
||
<input
|
||
value={newLabel}
|
||
onChange={(e) => setNewLabel(e.target.value)}
|
||
placeholder="Event name (e.g. Return Initiated)"
|
||
className="flex-1 px-4 py-2 rounded-lg bg-white border border-gray-300 text-gray-800 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-primary-blue text-sm "
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={addingEvent || !newLabel.trim()}
|
||
className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition disabled:opacity-50"
|
||
>
|
||
{addingEvent ? 'Adding…' : 'Add'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{eventSections.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center ">
|
||
<p className="text-base font-semibold text-gray-800">No events match your search.</p>
|
||
<p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the customer-facing lifecycle list.</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
{eventSections.map((section) => (
|
||
<section key={section.id} className="space-y-3">
|
||
<div className="px-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h2 className="text-lg font-bold tracking-tight text-gray-800">{section.label}</h2>
|
||
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||
{section.events.length} events
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 text-sm font-medium text-gray-500">{section.description}</p>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
{section.events.map((event) => {
|
||
const state = genState[event.slug] || 'idle';
|
||
const eventVariants = variants[event.slug] || [];
|
||
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 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">
|
||
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="flex items-start gap-4">
|
||
{event.isDefault ? (
|
||
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => handleDelete(event.slug)}
|
||
className="mt-0.5 w-6 h-6 rounded-full bg-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 transition shrink-0"
|
||
title="Delete event"
|
||
>
|
||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||
</button>
|
||
)}
|
||
<div>
|
||
<h3 className="text-base font-bold text-gray-800 tracking-tight">{event.label}</h3>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||
<span
|
||
title={statusConfig.label}
|
||
aria-label={statusConfig.label}
|
||
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
|
||
>
|
||
{statusConfig.label}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
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…</>
|
||
) : hasSelectedTemplate ? (
|
||
<>View Template</>
|
||
) : canOpenGenerationWorkspace ? (
|
||
<>Open Drafts</>
|
||
) : (
|
||
<>Generate Template</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{workspaceEvent && (
|
||
<TemplateGenerationWorkspaceModal
|
||
eventSlug={workspaceEvent.slug}
|
||
eventLabel={workspaceEvent.label}
|
||
statusConfig={workspaceTemplateStatus === 'unselected' ? null : workspaceStatusConfig}
|
||
loading={genState[workspaceSlug] === 'loading'}
|
||
generatedVariants={workspaceVariants}
|
||
selectedTemplatePreview={workspaceSelectedTemplatePreview}
|
||
activeDraftKey={workspaceActiveDraftKey}
|
||
activeDraft={workspaceDraft}
|
||
openVariableMenuKey={openVariableMenuKey}
|
||
onClose={requestCloseTemplateWorkspace}
|
||
onChooseVariant={(index) => handleChooseDraft(workspaceSlug, getVariantKey(workspaceSlug, index))}
|
||
onDraftChange={(nextText) => handleVariantChange(workspaceSlug, workspaceActiveDraftKey, nextText)}
|
||
onTrackSelection={(target) => trackTextareaSelection(workspaceActiveDraftKey, target)}
|
||
onToggleVariableMenu={() => handleVariableMenuToggle(workspaceActiveDraftKey)}
|
||
onInsertVariable={(token) => insertVariableToken(workspaceSlug, workspaceActiveDraftKey, token)}
|
||
onRevert={() => handleRevertVariant(workspaceSlug, workspaceActiveDraftKey)}
|
||
onCheck={() => handleValidateEdit(workspaceSlug, workspaceActiveDraftKey)}
|
||
onSelect={() => handleSelect(workspaceSlug, workspaceActiveDraftKey)}
|
||
onRegenerate={() => handleRegenerate(workspaceSlug)}
|
||
setVariableMenuRef={(node) => {
|
||
if (!workspaceActiveDraftKey) return;
|
||
if (node) variableMenuRefs.current[workspaceActiveDraftKey] = node;
|
||
else delete variableMenuRefs.current[workspaceActiveDraftKey];
|
||
}}
|
||
setTextareaRef={(node) => {
|
||
if (!workspaceActiveDraftKey) return;
|
||
if (node) textareaRefs.current[workspaceActiveDraftKey] = node;
|
||
else delete textareaRefs.current[workspaceActiveDraftKey];
|
||
}}
|
||
workspaceError={workspaceError}
|
||
selectingDraftKey={selectingVariantKey}
|
||
showClosePrompt={showClosePrompt}
|
||
closePromptTitle={workspaceClosePromptTitle}
|
||
closePromptDescription={workspaceClosePromptDescription}
|
||
discardingWorkspace={discardingWorkspace}
|
||
onKeepWorkspace={keepTemplateWorkspace}
|
||
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}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|