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: true, }, { id: 'delivery', label: 'Delivery Journey', description: 'Courier pickup, in-transit updates, and final handover milestones.', defaultExpanded: true, }, { 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; }, {}); 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 = {}; templates.forEach((template) => { if (!template?.eventSlug) return; if (template.selectedTemplate) { nextGenState[template.eventSlug] = 'selected'; return; } if (Array.isArray(template.generatedVariants) && template.generatedVariants.length > 0) { nextVariants[template.eventSlug] = template.generatedVariants; nextGenState[template.eventSlug] = 'done'; } }); return { nextVariants, nextGenState }; } 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 [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 } = buildTemplateUiState(templates); setEvents(eventsRes.data.events || []); setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl); setVariants(nextVariants); setGenState(nextGenState); 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(''); 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 { await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); await refreshOnboardingState(businessId).catch(() => null); setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] })); setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug)); setOpenVariableMenuKey(''); setActiveCaretVariantKey(''); setGenState((state) => ({ ...state, [slug]: 'selected' })); if (shouldAutoAdvance) { navigate(`/${businessId}/templates`); } } 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 (
Generate SMS templates for each order event.
No events match your search.
Try a different keyword or clear the search to see the full lifecycle list.
Review, edit, and choose a variant
Insert DLT Variable