import axios from 'axios';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
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 DELIVERY_EVENT_SLUGS = new Set([
'out_for_pickup',
'bag_picked',
'bag_reached_drop_point',
'in_transit',
'out_for_delivery',
'delivery_attempt_failed',
'delivery_done',
'handed_over_to_customer',
'bag_lost',
]);
const CANCELLATION_EVENT_SLUGS = new Set([
'bag_not_confirmed',
'cancelled_at_dp',
'cancelled_customer',
'cancelled_failed_at_dp',
'cancelled_fynd',
'rejected_by_customer',
]);
const REFUND_EVENT_SLUGS = new Set([
'credit_note_generated',
'partial_refund_completed',
'refund_acknowledged',
'refund_approved',
'refund_completed',
'refund_failed',
'refund_initiated',
'refund_on_hold',
'refund_pending',
'refund_pending_for_approval',
'refund_retry',
]);
const RETURN_EVENT_SLUGS = new Set([
'assigning_return_dp',
'internal_return_dp_reassign',
'deadstock_defective',
'deadstock_defective_lost',
]);
const EVENT_GROUPS = [
{
id: 'fulfillment',
label: 'Order & Fulfillment',
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
defaultExpanded: false,
},
{
id: 'delivery',
label: 'Delivery Journey',
description: 'Courier pickup, in-transit updates, and final handover milestones.',
defaultExpanded: false,
},
{
id: 'cancellations',
label: 'Cancellations & Rejections',
description: 'Customer, merchant, and delivery-partner driven cancellations and rejections.',
defaultExpanded: false,
},
{
id: 'returns',
label: 'Returns',
description: 'Return initiation, pickup, transit, and merchant-side return handling.',
defaultExpanded: false,
},
{
id: 'refunds',
label: 'Refunds',
description: 'Refund processing and credit-note states across payment flows.',
defaultExpanded: false,
},
{
id: 'rto',
label: 'RTO',
description: 'Return-to-origin movement and completion states after failed delivery.',
defaultExpanded: false,
},
{
id: 'custom',
label: 'Custom Events',
description: 'Business-specific events you added manually for your own messaging flows.',
defaultExpanded: false,
},
];
const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
acc[group.id] = group.defaultExpanded;
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',
},
};
const EVENT_GROUP_STYLE_CONFIG = {
fulfillment: {
markerShell: 'border-slate-200 bg-slate-50',
markerDot: 'bg-slate-500',
},
delivery: {
markerShell: 'border-sky-200 bg-sky-50',
markerDot: 'bg-sky-500',
},
cancellations: {
markerShell: 'border-rose-200 bg-rose-50',
markerDot: 'bg-rose-500',
},
returns: {
markerShell: 'border-indigo-200 bg-indigo-50',
markerDot: 'bg-indigo-500',
},
refunds: {
markerShell: 'border-emerald-200 bg-emerald-50',
markerDot: 'bg-emerald-500',
},
rto: {
markerShell: 'border-fuchsia-200 bg-fuchsia-50',
markerDot: 'bg-fuchsia-500',
},
custom: {
markerShell: 'border-indigo-200 bg-indigo-50',
markerDot: 'bg-indigo-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
: {},
curlProfileId: String(template?.curlProfileId || '').trim(),
};
}
function getEventGroupId(event) {
const slug = String(event?.slug || '');
if (!event?.isDefault) return 'custom';
if (slug.startsWith('rto_') || slug === 'return_to_origin') return 'rto';
if (slug.startsWith('return_') || RETURN_EVENT_SLUGS.has(slug)) return 'returns';
if (slug.startsWith('refund_') || REFUND_EVENT_SLUGS.has(slug)) return 'refunds';
if (CANCELLATION_EVENT_SLUGS.has(slug)) return 'cancellations';
if (DELIVERY_EVENT_SLUGS.has(slug)) return 'delivery';
return 'fulfillment';
}
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 buildGroupedEvents(events, searchTerm) {
return EVENT_GROUPS.map((group) => ({
...group,
events: events.filter((event) => getEventGroupId(event) === group.id && matchesEventSearch(event, searchTerm)),
})).filter((group) => group.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: '',
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 TemplateWorkspaceModal({
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 (
{eventLabel}
{statusConfig && (
{statusConfig.label}
)}
{loading ? (
) : (
<>
{workspaceError && (
{workspaceError}
)}
{generatedVariants.length > 0 ? (
Variants
{generatedVariants.length} option{generatedVariants.length === 1 ? '' : 's'}
{generatedVariants.map((variant, index) => {
const variantKey = getVariantKey(eventSlug, index);
const isActive = activeVariantIndex === index;
return (
);
})}
) : selectedTemplatePreview ? (
{selectedTemplatePreview.templateId ? (
Template ID {selectedTemplatePreview.templateId}
) : (
Template ID pending
)}
) : null}
{hasChosenDraft ? (
{getDraftSuffix(activeDraftKey) === 'selected' ? 'Current Template' : `Variant ${activeVariantIndex + 1}`}
{isEdited ? 'Edited Draft' : 'Working Draft'}
{validationStatus === 'checking' && (
Checking
)}
{validationStatus === 'approved' && currentMatchesCheckedText && (
Passed
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && (
Needs changes
)}
{openVariableMenuKey === activeDraftKey && (
{DLT_VARIABLE_OPTIONS.map((option) => (
))}
)}
) : (
Choose a generated variant to begin.
)}
>
)}
{showClosePrompt && (
{closePromptTitle}
{closePromptDescription}
)}
);
}
export default function Events() {
const { businessId } = useParams();
const navigate = useNavigate();
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 [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
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 [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?.rawCurl);
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 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 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: '',
lastCheckedText: '',
},
}));
setWorkspaceError('');
try {
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/validate-edit`, {
editedTemplate,
});
setVariantDrafts((currentDrafts) => ({
...currentDrafts,
[draftKey]: {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: res.data?.approved ? 'approved' : 'rejected',
why: res.data?.why || '',
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: '',
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: '',
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 });
}
function toggleGroup(groupId) {
setExpandedGroups((current) => ({
...current,
[groupId]: !current[groupId],
}));
}
if (loading) {
return (
);
}
const groupedEvents = buildGroupedEvents(events, searchTerm);
const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.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 (
<>
Events
Generate SMS templates for each order event.
{totalVisibleEvents} visible
{!readyToGenerate && (
⚠️
Set up and activate a cURL profile before generating templates.
)}
{error && (
{error}
)}
{showAddForm && (
)}
{groupedEvents.length === 0 ? (
No events match your search.
Try a different keyword or clear the search to see the full lifecycle list.
) : (
{groupedEvents.map((group) => {
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
return (
{isExpanded && (
{group.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 canViewTemplate = templateStatus !== 'unselected';
const hasWorkspaceContent = eventVariants.length > 0 || !!selectedTemplatePreview;
return (
{event.isDefault ? (
) : (
)}
{event.label}
{selectedTemplatePreview && (
Selected Template
{selectedTemplatePreview.selectedTemplate}
{selectedTemplatePreview.templateId ? (
Template ID {selectedTemplatePreview.templateId}
) : (
Template ID pending
)}
)}
{statusConfig.label}
{canViewTemplate && (
)}
);
})}
)}
);
})}
)}
{workspaceEvent && (
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}
/>
)}
>
);
}