960 lines
42 KiB
JavaScript
960 lines
42 KiB
JavaScript
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">×</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>
|
||
);
|
||
}
|