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: '#var', token: '{#var#}' }, { label: '#numeric', token: '{#numeric#}' }, { label: '#url', token: '{#url#}' }, { label: '#cbn', token: '{#cbn#}' }, ]; const DLT_TOKEN_SET = new Set(DLT_VARIABLE_OPTIONS.map((option) => option.token)); const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|cbn)#\}/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 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); return; } if (Array.isArray(template.generatedVariants) && template.generatedVariants.length > 0) { nextVariants[template.eventSlug] = template.generatedVariants; nextGenState[template.eventSlug] = 'done'; } }); return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug }; } export default function Events() { const { businessId } = useParams(); const navigate = useNavigate(); const { hasSelectedTemplates, 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 [selectingVariantKey, setSelectingVariantKey] = useState(''); const [openVariableMenuKey, setOpenVariableMenuKey] = useState(''); const [activeCaretVariantKey, setActiveCaretVariantKey] = useState(''); const [templateStatusBySlug, setTemplateStatusBySlug] = useState({}); const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({}); const [error, setError] = useState(''); const [readyToGenerate, setReadyToGenerate] = useState(false); const textareaRefs = useRef({}); const selectionStateRef = useRef({}); const variableMenuRefs = useRef({}); 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]); 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'); } } async function handleGenerate(slug) { if (!readyToGenerate) { setError('Configure and activate a cURL profile before generating templates.'); return; } setGenState((state) => ({ ...state, [slug]: 'loading' })); setError(''); try { const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`); const generatedVariants = res.data.variants || []; setVariants((currentVariants) => ({ ...currentVariants, [slug]: generatedVariants })); setVariantDrafts((currentDrafts) => ({ ...removeDraftsForSlug(currentDrafts, slug), ...buildDraftsForVariants(slug, generatedVariants), })); setOpenVariableMenuKey(''); setActiveCaretVariantKey(''); setTemplateStatusBySlug((currentStatuses) => { const nextStatuses = { ...currentStatuses }; delete nextStatuses[slug]; return nextStatuses; }); setSelectedTemplateBySlug((currentTemplates) => { const nextTemplates = { ...currentTemplates }; delete nextTemplates[slug]; return nextTemplates; }); setGenState((state) => ({ ...state, [slug]: 'done' })); } catch (err) { setError(err.response?.data?.error || 'Generation failed'); setGenState((state) => ({ ...state, [slug]: 'error' })); } } async function handleValidateEdit(slug, variantIndex) { const variantKey = getVariantKey(slug, variantIndex); const draft = variantDrafts[variantKey]; const editedTemplate = draft?.currentText || ''; if (!editedTemplate) return; setVariantDrafts((currentDrafts) => ({ ...currentDrafts, [variantKey]: { ...(currentDrafts[variantKey] || createVariantDraft(editedTemplate)), validationStatus: 'checking', why: '', lastCheckedText: '', }, })); setError(''); try { const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/validate-edit`, { editedTemplate, }); setVariantDrafts((currentDrafts) => ({ ...currentDrafts, [variantKey]: { ...(currentDrafts[variantKey] || createVariantDraft(editedTemplate)), validationStatus: res.data?.approved ? 'approved' : 'rejected', why: res.data?.why || '', lastCheckedText: editedTemplate, }, })); } catch (err) { setError(err.response?.data?.error || 'Failed to validate edited template'); setVariantDrafts((currentDrafts) => ({ ...currentDrafts, [variantKey]: { ...(currentDrafts[variantKey] || createVariantDraft(editedTemplate)), validationStatus: 'idle', why: '', lastCheckedText: '', }, })); } } async function handleSelect(slug, variant, variantIndex) { const variantKey = getVariantKey(slug, variantIndex); const shouldAutoAdvance = !hasSelectedTemplates; setSelectingVariantKey(variantKey); setError(''); try { const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); const selectedTemplatePreview = buildSelectedTemplatePreview(res.data); await refreshOnboardingState(businessId).catch(() => null); setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] })); setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug)); setOpenVariableMenuKey(''); setActiveCaretVariantKey(''); setGenState((state) => ({ ...state, [slug]: 'selected' })); setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' })); setSelectedTemplateBySlug((currentTemplates) => ({ ...currentTemplates, [slug]: selectedTemplatePreview, })); if (shouldAutoAdvance) { navigate(`/${businessId}/templates?event=${encodeURIComponent(slug)}`); } } catch (err) { setError(err.response?.data?.error || 'Failed to select template'); } finally { setSelectingVariantKey(''); } } function handleVariantChange(slug, variantIndex, nextText) { const variantKey = getVariantKey(slug, variantIndex); const originalText = variantDrafts[variantKey]?.originalText || variants[slug]?.[variantIndex] || ''; setVariantDrafts((currentDrafts) => ({ ...currentDrafts, [variantKey]: { originalText, currentText: nextText, validationStatus: 'idle', why: '', lastCheckedText: '', }, })); } function handleRevertVariant(slug, variantIndex) { const variantKey = getVariantKey(slug, variantIndex); const originalText = variantDrafts[variantKey]?.originalText || variants[slug]?.[variantIndex] || ''; setVariantDrafts((currentDrafts) => ({ ...currentDrafts, [variantKey]: createVariantDraft(originalText), })); setOpenVariableMenuKey(''); } function trackTextareaSelection(variantKey, target) { selectionStateRef.current[variantKey] = { start: target.selectionStart ?? 0, end: target.selectionEnd ?? 0, }; setActiveCaretVariantKey(variantKey); } function handleVariableMenuToggle(variantKey) { setOpenVariableMenuKey((currentKey) => currentKey === variantKey ? '' : variantKey); } function insertVariableToken(slug, variantIndex, token) { const variantKey = getVariantKey(slug, variantIndex); const draft = variantDrafts[variantKey] || createVariantDraft(variants[slug]?.[variantIndex] || ''); const textarea = textareaRefs.current[variantKey]; const selection = selectionStateRef.current[variantKey]; 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, variantIndex, nextText); setOpenVariableMenuKey(''); requestAnimationFrame(() => { const nextCaretPosition = start + token.length; textarea.focus(); textarea.setSelectionRange(nextCaretPosition, nextCaretPosition); selectionStateRef.current[variantKey] = { start: nextCaretPosition, end: nextCaretPosition, }; setActiveCaretVariantKey(variantKey); }); } 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); return (

Events

Generate SMS templates for each order event.

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 && ( )}
{totalVisibleEvents} visible
{!readyToGenerate && (
⚠️ Set up and activate a cURL profile before generating templates.
)} {error && (
{error}
)} {showAddForm && (
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 />
)} {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'; return (
{event.isDefault ? (
) : ( )}

{event.label}

{selectedTemplatePreview && (

Selected Template

{selectedTemplatePreview.selectedTemplate}

{selectedTemplatePreview.templateId ? ( Template ID {selectedTemplatePreview.templateId} ) : ( Template ID pending )}
)}
{statusConfig.label} {canViewTemplate && ( )}
{eventVariants.length > 0 && (

Review, edit, and choose a variant

{eventVariants.map((variant, index) => { const variantKey = getVariantKey(event.slug, index); const draft = variantDrafts[variantKey] || createVariantDraft(variant); const currentText = draft.currentText; const originalText = draft.originalText; const validationStatus = draft.validationStatus; const currentMatchesCheckedText = draft.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 isSelectingThis = selectingVariantKey === variantKey; const isSelectingAnotherVariant = !!selectingVariantKey && selectingVariantKey !== variantKey && selectingVariantKey.startsWith(`${event.slug}:`); const canRunCheck = isEdited && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking'; const canUseEdited = isEdited && validationStatus === 'approved' && currentMatchesCheckedText && !tooLong && !hasInvalidPlaceholder; const canInsertVariable = activeCaretVariantKey === variantKey; return (
{isEdited ? 'Edited Draft' : 'Original Draft'} {validationStatus === 'checking' && ( Checking edit… )} {validationStatus === 'approved' && currentMatchesCheckedText && ( Edit passed check )} {validationStatus === 'rejected' && currentMatchesCheckedText && ( Needs changes )}
{ if (node) variableMenuRefs.current[variantKey] = node; else delete variableMenuRefs.current[variantKey]; }} > {openVariableMenuKey === variantKey && (

Insert DLT Variable

{DLT_VARIABLE_OPTIONS.map((option) => ( ))}
)}