sms-extension-1777538290/client/src/pages/Events.jsx

960 lines
42 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}, {});
const EVENT_TEMPLATE_STATUS_CONFIG = {
unselected: {
label: 'No template selected',
wrapper: 'border-gray-200 bg-gray-50 text-gray-500',
dot: 'bg-gray-400',
},
pending_whitelisting: {
label: 'Pending Whitelisting',
wrapper: 'border-amber-200 bg-amber-50 text-amber-700',
dot: 'bg-amber-500',
},
whitelisted: {
label: 'Published',
wrapper: 'border-green-200 bg-green-50 text-green-700',
dot: 'bg-green-500',
},
};
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 = {};
templates.forEach((template) => {
if (!template?.eventSlug) return;
if (template.selectedTemplate) {
if (template.status === 'whitelisted') {
nextTemplateStatusBySlug[template.eventSlug] = 'whitelisted';
} else {
nextTemplateStatusBySlug[template.eventSlug] = 'pending_whitelisting';
}
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, nextTemplateStatusBySlug };
}
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 [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 } = buildTemplateUiState(templates);
setEvents(eventsRes.data.events || []);
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
setVariants(nextVariants);
setGenState(nextGenState);
setTemplateStatusBySlug(nextTemplateStatusBySlug);
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;
});
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' }));
setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' }));
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 (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin" />
</div>
);
}
const groupedEvents = buildGroupedEvents(events, searchTerm);
const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.events.length, 0);
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-900 tracking-tight">Events</h1>
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</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-xl border border-gray-300 bg-white py-3 pl-11 pr-10 text-sm font-medium text-gray-900 placeholder-gray-400 shadow-sm transition focus:border-indigo-300 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-indigo-100 bg-indigo-50 px-3 py-2 text-xs font-semibold text-indigo-700">
{totalVisibleEvents} visible
</span>
<button
onClick={() => setShowAddForm((visible) => !visible)}
className="px-4 py-2 rounded-lg bg-white border border-gray-300 shadow-sm text-sm text-gray-700 font-semibold hover:bg-gray-50 transition"
>
{showAddForm ? 'Cancel' : '+ Add Event'}
</button>
</div>
</div>
</div>
{!readyToGenerate && (
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-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-3 rounded-md bg-red-50 border border-red-200 text-red-700 font-medium text-sm flex items-center justify-between">
{error}
<button onClick={() => setError('')} className="text-red-500 hover:text-red-700 font-bold">&times;</button>
</div>
)}
{showAddForm && (
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-xl bg-gray-50 border border-gray-200 shadow-sm">
<input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Event name (e.g. Return Initiated)"
className="flex-1 px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 text-sm shadow-sm"
autoFocus
/>
<button
type="submit"
disabled={addingEvent || !newLabel.trim()}
className="px-6 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition shadow-sm disabled:opacity-50"
>
{addingEvent ? 'Adding…' : 'Add'}
</button>
</form>
)}
{groupedEvents.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-6 py-12 text-center shadow-sm">
<p className="text-base font-semibold text-gray-900">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 full lifecycle list.</p>
</div>
) : (
<div className="space-y-4">
{groupedEvents.map((group) => {
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
return (
<section key={group.id} className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
<button
type="button"
onClick={() => toggleGroup(group.id)}
className="flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50"
>
<div className="flex min-w-0 items-start gap-4">
<div className={`mt-1 h-3 w-3 rounded-full shadow-sm ${
group.id === 'fulfillment' ? 'bg-indigo-500' :
group.id === 'delivery' ? 'bg-sky-500' :
group.id === 'cancellations' ? 'bg-rose-500' :
group.id === 'returns' ? 'bg-amber-500' :
group.id === 'refunds' ? 'bg-emerald-500' :
group.id === 'rto' ? 'bg-fuchsia-500' :
'bg-gray-500'
}`} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-900">{group.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">
{group.events.length} events
</span>
</div>
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
</div>
</div>
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition ${
isExpanded ? 'rotate-180' : ''
}`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
</svg>
</span>
</button>
{isExpanded && (
<div className="border-t border-gray-100 bg-gray-50/50 px-4 py-4 sm:px-6">
<div className="space-y-4">
{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 canViewTemplate = templateStatus !== 'unselected';
return (
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4">
<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-red-50 hover:bg-red-100 flex items-center justify-center border border-red-100 text-red-500 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-900 tracking-tight">{event.label}</h3>
</div>
</div>
<div className="flex items-center gap-3">
<span
title={statusConfig.label}
aria-label={statusConfig.label}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border shadow-sm ${statusConfig.wrapper}`}
>
<span className={`h-2.5 w-2.5 rounded-full ${statusConfig.dot}`} />
</span>
{canViewTemplate && (
<button
type="button"
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 transition shadow-sm"
>
View in Templates
</button>
)}
<button
onClick={() => handleGenerate(event.slug)}
disabled={state === 'loading' || !readyToGenerate}
className={`px-4 py-2 rounded-lg text-sm font-medium transition shadow-sm flex items-center gap-2 disabled:opacity-50 ${
state === 'done' || state === 'selected'
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
: 'bg-indigo-50 border border-indigo-200 text-indigo-700 hover:bg-indigo-100'
}`}
>
{state === 'loading' ? (
<><span className="w-4 h-4 border-2 border-indigo-300 border-t-indigo-600 rounded-full animate-spin" /> Generating</>
) : state === 'done' || state === 'selected' ? (
<> Regenerate</>
) : (
<> Generate Template</>
)}
</button>
</div>
</div>
{eventVariants.length > 0 && (
<div className="border-t border-gray-100 bg-gray-50/50 px-6 py-5 space-y-4">
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p>
<div className="grid gap-4">
{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 (
<div
key={variantKey}
className={`rounded-xl border bg-white p-5 shadow-sm transition ${
isSelectingThis
? 'border-indigo-300 ring-2 ring-indigo-100'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
isEdited
? 'bg-amber-50 border-amber-200 text-amber-700'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}>
{isEdited ? 'Edited Draft' : 'Original Draft'}
</span>
{validationStatus === 'checking' && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-blue-50 border-blue-200 text-blue-700">
Checking edit
</span>
)}
{validationStatus === 'approved' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-green-50 border-green-200 text-green-700">
Edit passed check
</span>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-red-50 border-red-200 text-red-700">
Needs changes
</span>
)}
</div>
<div
className="relative"
ref={(node) => {
if (node) variableMenuRefs.current[variantKey] = node;
else delete variableMenuRefs.current[variantKey];
}}
>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleVariableMenuToggle(variantKey)}
disabled={!canInsertVariable}
className="text-xs px-3 py-2 rounded-md bg-white border border-indigo-200 text-indigo-700 font-semibold hover:bg-indigo-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
# Add Variable
</button>
{openVariableMenuKey === variantKey && (
<div className="absolute right-0 z-20 mt-2 w-56 rounded-xl border border-gray-200 bg-white shadow-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">Insert DLT Variable</p>
</div>
<div className="py-1">
{DLT_VARIABLE_OPTIONS.map((option) => (
<button
key={option.token}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertVariableToken(event.slug, index, option.token)}
className="w-full px-4 py-3 text-left hover:bg-indigo-50 transition flex items-center justify-between gap-3"
>
<span className="text-sm font-semibold text-gray-800">{option.label}</span>
<span className="text-xs font-mono text-indigo-700">{option.token}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<textarea
ref={(node) => {
if (node) textareaRefs.current[variantKey] = node;
else delete textareaRefs.current[variantKey];
}}
value={currentText}
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
onFocus={(e) => trackTextareaSelection(variantKey, e.target)}
onClick={(e) => trackTextareaSelection(variantKey, e.target)}
onSelect={(e) => trackTextareaSelection(variantKey, e.target)}
onKeyUp={(e) => trackTextareaSelection(variantKey, e.target)}
rows={4}
className={`w-full rounded-xl border px-4 py-3 text-sm text-gray-800 font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 ${
isEdited
? 'border-amber-200 bg-amber-50/40 focus:ring-amber-200 focus:border-amber-300'
: 'border-gray-200 bg-gray-50 focus:ring-indigo-100 focus:border-indigo-300'
}`}
/>
<div className="flex flex-wrap items-center justify-between gap-3 mt-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
tooLong
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-gray-100 border-gray-200 text-gray-600'
}`}>
{currentText.length} / {MAX_SMS_LENGTH}
</span>
<span className="text-xs font-semibold px-2.5 py-1 rounded-md border bg-indigo-50 border-indigo-200 text-indigo-700">
DLT vars: {dltTokenCount}
</span>
</div>
{isEdited && (
<button
onClick={() => handleRevertVariant(event.slug, index)}
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50 transition"
>
Revert to original
</button>
)}
</div>
{isEdited && (
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p>
<p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
</div>
)}
{invalidDltTokens.length > 0 && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Unsupported DLT variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>.
Use only {DLT_VARIABLE_OPTIONS.map((option) => option.token).join(', ')}.
</div>
)}
{hasMalformedDltToken && invalidDltTokens.length === 0 && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Finish or remove incomplete DLT placeholder text before checking or selecting this edit.
</div>
)}
{tooLong && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version.
</div>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<span className="font-semibold">Why it did not pass:</span> {draft.why}
</div>
)}
<div className="flex flex-wrap items-center gap-2 mt-4">
{!isEdited ? (
<button
onClick={() => handleSelect(event.slug, currentText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use this template'}
</button>
) : (
<>
<button
onClick={() => handleSelect(event.slug, originalText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-gray-50 transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use original'}
</button>
{canUseEdited ? (
<button
onClick={() => handleSelect(event.slug, currentText, index)}
disabled={isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-green-600 hover:bg-green-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSelectingThis ? 'Selecting…' : 'Use edited version'}
</button>
) : (
<button
onClick={() => handleValidateEdit(event.slug, index)}
disabled={!canRunCheck || isSelectingThis || isSelectingAnotherVariant}
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
>
{validationStatus === 'checking'
? 'Checking…'
: validationStatus === 'rejected' && currentMatchesCheckedText
? 'Check again'
: 'Check edit'}
</button>
)}
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
</section>
);
})}
</div>
)}
</div>
);
}