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

1517 lines
66 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 axios from 'axios';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
const MAX_SMS_LENGTH = 160;
const DLT_VARIABLE_OPTIONS = [
{ label: 'Generic Text', token: '{#var#}' },
{ label: 'Numeric', token: '{#numeric#}' },
{ label: 'URL', token: '{#url#}' },
{ label: 'URL OTT', token: '{#urlott#}' },
{ label: 'Callback Number', token: '{#cbn#}' },
{ label: 'Email', token: '{#email#}' },
{ label: 'Alphanumeric', token: '{#alphanumeric#}' },
];
const SUPPORTED_DLT_VARIABLE_OPTIONS = DLT_VARIABLE_OPTIONS;
const DLT_TOKEN_SET = new Set(SUPPORTED_DLT_VARIABLE_OPTIONS.map((option) => option.token));
const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
const DELIVERY_EVENT_SLUGS = new Set([
'out_for_pickup',
'bag_picked',
'bag_reached_drop_point',
'in_transit',
'out_for_delivery',
'delivery_attempt_failed',
'delivery_done',
'handed_over_to_customer',
'bag_lost',
]);
const CANCELLATION_EVENT_SLUGS = new Set([
'bag_not_confirmed',
'cancelled_at_dp',
'cancelled_customer',
'cancelled_failed_at_dp',
'cancelled_fynd',
'rejected_by_customer',
]);
const REFUND_EVENT_SLUGS = new Set([
'credit_note_generated',
'partial_refund_completed',
'refund_acknowledged',
'refund_approved',
'refund_completed',
'refund_failed',
'refund_initiated',
'refund_on_hold',
'refund_pending',
'refund_pending_for_approval',
'refund_retry',
]);
const RETURN_EVENT_SLUGS = new Set([
'assigning_return_dp',
'internal_return_dp_reassign',
'deadstock_defective',
'deadstock_defective_lost',
]);
const EVENT_GROUPS = [
{
id: 'fulfillment',
label: 'Order & Fulfillment',
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
defaultExpanded: false,
},
{
id: 'delivery',
label: 'Delivery Journey',
description: 'Courier pickup, in-transit updates, and final handover milestones.',
defaultExpanded: false,
},
{
id: 'cancellations',
label: 'Cancellations & Rejections',
description: 'Customer, merchant, and delivery-partner driven cancellations and rejections.',
defaultExpanded: false,
},
{
id: 'returns',
label: 'Returns',
description: 'Return initiation, pickup, transit, and merchant-side return handling.',
defaultExpanded: false,
},
{
id: 'refunds',
label: 'Refunds',
description: 'Refund processing and credit-note states across payment flows.',
defaultExpanded: false,
},
{
id: 'rto',
label: 'RTO',
description: 'Return-to-origin movement and completion states after failed delivery.',
defaultExpanded: false,
},
{
id: 'custom',
label: 'Custom Events',
description: 'Business-specific events you added manually for your own messaging flows.',
defaultExpanded: false,
},
];
const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
acc[group.id] = group.defaultExpanded;
return acc;
}, {});
const EVENT_TEMPLATE_STATUS_CONFIG = {
unselected: {
label: 'Not Selected',
badge: 'border-gray-200 bg-white text-gray-500',
dot: 'bg-gray-400',
},
pending_whitelisting: {
label: 'Pending Whitelisting',
badge: 'border-amber-200 bg-amber-50 text-amber-700',
dot: 'bg-amber-500',
},
whitelisted: {
label: 'Published',
badge: 'border-emerald-200 bg-emerald-50 text-emerald-700',
dot: 'bg-emerald-500',
},
};
const EVENT_GROUP_STYLE_CONFIG = {
fulfillment: {
markerShell: 'border-slate-200 bg-slate-50',
markerDot: 'bg-slate-500',
},
delivery: {
markerShell: 'border-sky-200 bg-sky-50',
markerDot: 'bg-sky-500',
},
cancellations: {
markerShell: 'border-rose-200 bg-rose-50',
markerDot: 'bg-rose-500',
},
returns: {
markerShell: 'border-indigo-200 bg-indigo-50',
markerDot: 'bg-indigo-500',
},
refunds: {
markerShell: 'border-emerald-200 bg-emerald-50',
markerDot: 'bg-emerald-500',
},
rto: {
markerShell: 'border-fuchsia-200 bg-fuchsia-50',
markerDot: 'bg-fuchsia-500',
},
custom: {
markerShell: 'border-indigo-200 bg-indigo-50',
markerDot: 'bg-indigo-500',
},
};
function normalizeTemplateStatus(status) {
return status === 'whitelisted' ? 'whitelisted' : 'pending_whitelisting';
}
function buildSelectedTemplatePreview(template = {}) {
const selectedTemplate = String(template?.selectedTemplate || '').trim();
if (!selectedTemplate) return null;
return {
eventSlug: String(template?.eventSlug || '').trim(),
selectedTemplate,
status: normalizeTemplateStatus(template?.status),
templateId: String(template?.templateId || '').trim(),
variableMap: template?.variableMap && typeof template.variableMap === 'object'
? template.variableMap
: {},
curlProfileId: String(template?.curlProfileId || '').trim(),
};
}
function getEventGroupId(event) {
const slug = String(event?.slug || '');
if (!event?.isDefault) return 'custom';
if (slug.startsWith('rto_') || slug === 'return_to_origin') return 'rto';
if (slug.startsWith('return_') || RETURN_EVENT_SLUGS.has(slug)) return 'returns';
if (slug.startsWith('refund_') || REFUND_EVENT_SLUGS.has(slug)) return 'refunds';
if (CANCELLATION_EVENT_SLUGS.has(slug)) return 'cancellations';
if (DELIVERY_EVENT_SLUGS.has(slug)) return 'delivery';
return 'fulfillment';
}
function matchesEventSearch(event, searchTerm) {
const query = String(searchTerm || '').trim().toLowerCase();
if (!query) return true;
return [event?.label, event?.slug]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
}
function buildGroupedEvents(events, searchTerm) {
return EVENT_GROUPS.map((group) => ({
...group,
events: events.filter((event) => getEventGroupId(event) === group.id && matchesEventSearch(event, searchTerm)),
})).filter((group) => group.events.length > 0);
}
function getVariantKey(slug, index) {
return `${slug}:${index}`;
}
function getSelectedDraftKey(slug) {
return `${slug}:selected`;
}
function getDraftSuffix(draftKey = '') {
const separatorIndex = String(draftKey).lastIndexOf(':');
return separatorIndex === -1 ? '' : String(draftKey).slice(separatorIndex + 1);
}
function getVariantIndexFromDraftKey(draftKey = '') {
const suffix = getDraftSuffix(draftKey);
if (!suffix) return null;
if (suffix === 'selected') return null;
const index = Number(suffix);
return Number.isInteger(index) ? index : null;
}
function resolveDraftBaseText(slug, draftKey, variantsBySlug, selectedTemplateBySlug) {
if (!draftKey) return '';
const draftSuffix = getDraftSuffix(draftKey);
if (draftSuffix === 'selected') {
return String(selectedTemplateBySlug?.[slug]?.selectedTemplate || '');
}
const variantIndex = Number(draftSuffix);
if (!Number.isInteger(variantIndex)) return '';
return String(variantsBySlug?.[slug]?.[variantIndex] || '');
}
function createVariantDraft(text = '') {
return {
originalText: text,
currentText: text,
validationStatus: 'idle',
why: '',
lastCheckedText: '',
};
}
function getDltTokens(text = '') {
return String(text).match(DLT_TOKEN_REGEX) || [];
}
function countDltTokens(text = '') {
return getDltTokens(text).length;
}
function getInvalidDltTokens(text = '') {
return (String(text).match(DLT_TOKEN_LIKE_REGEX) || []).filter((token) => !DLT_TOKEN_SET.has(token));
}
function hasMalformedDltFragments(text = '') {
const strippedText = String(text).replace(DLT_TOKEN_LIKE_REGEX, '');
return strippedText.includes('{#') || strippedText.includes('#}');
}
function buildDraftsForVariants(slug, generatedVariants = []) {
const nextDrafts = {};
generatedVariants.forEach((variant, index) => {
nextDrafts[getVariantKey(slug, index)] = createVariantDraft(variant);
});
return nextDrafts;
}
function removeDraftsForSlug(drafts, slug) {
const nextDrafts = { ...drafts };
Object.keys(nextDrafts).forEach((key) => {
if (key.startsWith(`${slug}:`)) delete nextDrafts[key];
});
return nextDrafts;
}
function syncDraftsWithVariants(existingDrafts, variantsBySlug) {
const nextDrafts = {};
Object.entries(variantsBySlug).forEach(([slug, generatedVariants]) => {
generatedVariants.forEach((variant, index) => {
const key = getVariantKey(slug, index);
const existing = existingDrafts[key];
nextDrafts[key] = existing && existing.originalText === variant
? existing
: createVariantDraft(variant);
});
});
return nextDrafts;
}
function buildTemplateUiState(templates = []) {
const nextVariants = {};
const nextGenState = {};
const nextTemplateStatusBySlug = {};
const nextSelectedTemplateBySlug = {};
templates.forEach((template) => {
if (!template?.eventSlug) return;
if (template.selectedTemplate) {
const normalizedStatus = normalizeTemplateStatus(template.status);
nextTemplateStatusBySlug[template.eventSlug] = normalizedStatus;
nextGenState[template.eventSlug] = 'selected';
nextSelectedTemplateBySlug[template.eventSlug] = buildSelectedTemplatePreview(template);
}
if (Array.isArray(template.generatedVariants) && template.generatedVariants.length > 0) {
nextVariants[template.eventSlug] = template.generatedVariants;
if (!template.selectedTemplate) nextGenState[template.eventSlug] = 'done';
}
});
return { nextVariants, nextGenState, nextTemplateStatusBySlug, nextSelectedTemplateBySlug };
}
function TemplateWorkspaceModal({
eventSlug,
eventLabel,
statusConfig,
loading,
generatedVariants,
selectedTemplatePreview,
activeDraftKey,
activeDraft,
openVariableMenuKey,
onClose,
onChooseVariant,
onDraftChange,
onTrackSelection,
onToggleVariableMenu,
onInsertVariable,
onRevert,
onCheck,
onSelect,
onRegenerate,
setVariableMenuRef,
setTextareaRef,
workspaceError,
selectingDraftKey,
showClosePrompt,
closePromptTitle,
closePromptDescription,
discardingWorkspace,
onKeepWorkspace,
onDiscardWorkspace,
}) {
const currentText = activeDraft?.currentText || '';
const originalText = activeDraft?.originalText || '';
const validationStatus = activeDraft?.validationStatus || 'idle';
const currentMatchesCheckedText = activeDraft?.lastCheckedText === currentText;
const isEdited = currentText !== originalText;
const dltTokenCount = countDltTokens(currentText);
const invalidDltTokens = getInvalidDltTokens(currentText);
const hasMalformedDltToken = hasMalformedDltFragments(currentText);
const hasInvalidPlaceholder = invalidDltTokens.length > 0 || hasMalformedDltToken;
const tooLong = currentText.length > MAX_SMS_LENGTH;
const hasChosenDraft = !!activeDraftKey;
const canRunCheck = hasChosenDraft && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking';
const failedCurrentCheck = validationStatus === 'rejected' && currentMatchesCheckedText;
const canSelectDraft = hasChosenDraft
&& !tooLong
&& !hasInvalidPlaceholder
&& !failedCurrentCheck
&& (!isEdited || (validationStatus === 'approved' && currentMatchesCheckedText));
const canInsertVariable = hasChosenDraft;
const activeVariantIndex = getVariantIndexFromDraftKey(activeDraftKey);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div
className="relative flex h-full w-full flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl"
style={{ width: '980px', maxWidth: 'calc(100vw - 2rem)', height: 'min(84vh, 820px)' }}
>
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-gray-900">{eventLabel}</h2>
{statusConfig && (
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold ${statusConfig.badge}`}>
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
{statusConfig.label}
</span>
)}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-50 hover:text-gray-900"
>
Close
</button>
</div>
{loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="h-10 w-10 rounded-full border-2 border-gray-200 border-t-primary-blue animate-spin" />
</div>
) : (
<>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-5 pb-2">
{workspaceError && (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700">
{workspaceError}
</div>
)}
{generatedVariants.length > 0 ? (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Variants</p>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
{generatedVariants.length} option{generatedVariants.length === 1 ? '' : 's'}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{generatedVariants.map((variant, index) => {
const variantKey = getVariantKey(eventSlug, index);
const isActive = activeVariantIndex === index;
return (
<button
key={variantKey}
type="button"
onClick={() => onChooseVariant(index)}
className={`flex min-h-[150px] flex-col rounded-xl border px-4 py-4 text-left transition ${isActive
? 'border-primary-blue bg-indigo-50/40 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">
Variant {index + 1}
</span>
{isActive && (
<span className="rounded-full border border-indigo-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-primary-dark">
In editor
</span>
)}
</div>
<p className="mt-3 line-clamp-5 text-sm leading-relaxed text-gray-700 break-words">
{variant}
</p>
</button>
);
})}
</div>
</section>
) : selectedTemplatePreview ? (
<section className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-4">
<div className="flex flex-wrap items-center gap-2">
{selectedTemplatePreview.templateId ? (
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-600">
Template ID {selectedTemplatePreview.templateId}
</span>
) : (
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
Template ID pending
</span>
)}
</div>
</section>
) : null}
<section className="rounded-xl border border-gray-200 bg-white">
{hasChosenDraft ? (
<div className="space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
{getDraftSuffix(activeDraftKey) === 'selected' ? 'Current Template' : `Variant ${activeVariantIndex + 1}`}
</span>
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
{isEdited ? 'Edited Draft' : 'Working Draft'}
</span>
{validationStatus === 'checking' && (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
Checking
</span>
)}
{validationStatus === 'approved' && currentMatchesCheckedText && (
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold text-emerald-700">
Passed
</span>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] font-semibold text-amber-700">
Needs changes
</span>
)}
</div>
<div
className="relative"
ref={setVariableMenuRef}
>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleVariableMenu}
disabled={!canInsertVariable}
className="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-primary-dark transition hover:bg-gray-50 hover:border-gray-300 disabled:cursor-not-allowed disabled:opacity-50"
>
# Add Variable
</button>
{openVariableMenuKey === activeDraftKey && (
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-lg border border-gray-200 bg-white">
<div className="border-b border-gray-100 px-4 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Insert variable</p>
</div>
<div className="py-1">
{DLT_VARIABLE_OPTIONS.map((option) => (
<button
key={option.token}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onInsertVariable(option.token)}
className="flex w-full items-center justify-between gap-3 px-4 py-2 text-left transition hover:bg-gray-50"
>
<span className="text-sm font-semibold text-gray-800">{option.label}</span>
<span className="text-xs font-mono text-primary-dark">{option.token}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
<textarea
ref={setTextareaRef}
value={currentText}
onChange={(e) => onDraftChange(e.target.value)}
onFocus={(e) => onTrackSelection(e.target)}
onClick={(e) => onTrackSelection(e.target)}
onSelect={(e) => onTrackSelection(e.target)}
onKeyUp={(e) => onTrackSelection(e.target)}
rows={6}
className={`w-full resize-y rounded-lg border px-4 py-3 font-mono text-sm leading-relaxed text-gray-800 focus:outline-none focus:ring-2 ${isEdited
? 'border-gray-200 bg-white focus:border-amber-300 focus:ring-amber-200'
: 'border-gray-200 bg-white focus:border-primary-blue focus:ring-indigo-100'
}`}
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${tooLong
? 'border-amber-200 bg-amber-50 text-amber-700'
: 'border-gray-200 bg-gray-50 text-gray-600'
}`}>
{currentText.length} / {MAX_SMS_LENGTH}
</span>
<span className="rounded-md border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-primary-dark">
DLT vars: {dltTokenCount}
</span>
</div>
{isEdited && (
<button
type="button"
onClick={onRevert}
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-xs font-semibold text-gray-700 transition hover:bg-gray-50 hover:border-gray-400"
>
Revert to original
</button>
)}
</div>
{isEdited && (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Original generated version</p>
<p className="mt-2 whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-gray-600">{originalText}</p>
</div>
)}
{invalidDltTokens.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Unsupported variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>
</div>
)}
{hasMalformedDltToken && invalidDltTokens.length === 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Finish or remove incomplete variable text before checking or selecting this draft.
</div>
)}
{tooLong && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Shorten this template to {MAX_SMS_LENGTH} characters or less before continuing.
</div>
)}
{validationStatus === 'rejected' && currentMatchesCheckedText && activeDraft?.why && (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700">
<span className="font-semibold">Why it did not pass:</span> {activeDraft.why}
</div>
)}
</div>
) : (
<div className="px-5 py-12 text-center text-sm font-medium text-gray-500">
Choose a generated variant to begin.
</div>
)}
</section>
</div>
</div>
<div className="shrink-0 flex flex-wrap items-center justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button
type="button"
onClick={onRegenerate}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 hover:border-gray-400"
>
Regenerate
</button>
<button
type="button"
onClick={onCheck}
disabled={!canRunCheck}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 hover:border-gray-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{validationStatus === 'checking' ? 'Checking…' : 'Check again'}
</button>
<button
type="button"
onClick={onSelect}
disabled={!canSelectDraft || selectingDraftKey === activeDraftKey}
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Select
</button>
</div>
</>
)}
{showClosePrompt && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/70 px-4 backdrop-blur-[1px]">
<div className="w-full max-w-sm rounded-2xl border border-gray-200 bg-white p-5 shadow-xl">
<div className="space-y-2">
<h3 className="text-lg font-semibold tracking-tight text-gray-900">{closePromptTitle}</h3>
<p className="text-sm leading-relaxed text-gray-500">{closePromptDescription}</p>
</div>
<div className="mt-5 flex items-center justify-end gap-3">
<button
type="button"
onClick={onDiscardWorkspace}
disabled={discardingWorkspace}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{discardingWorkspace ? 'Discarding…' : 'Discard'}
</button>
<button
type="button"
onClick={onKeepWorkspace}
disabled={discardingWorkspace}
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-50"
>
Keep
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function Events() {
const { businessId } = useParams();
const navigate = useNavigate();
const { refreshOnboardingState } = useBusiness();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [newLabel, setNewLabel] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [addingEvent, setAddingEvent] = useState(false);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
const [genState, setGenState] = useState({});
const [variants, setVariants] = useState({});
const [variantDrafts, setVariantDrafts] = useState({});
const [activeDraftKeyBySlug, setActiveDraftKeyBySlug] = useState({});
const [selectingVariantKey, setSelectingVariantKey] = useState('');
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
const [, setActiveCaretVariantKey] = useState('');
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
const [selectedTemplateBySlug, setSelectedTemplateBySlug] = useState({});
const [templateWorkspace, setTemplateWorkspace] = useState({ slug: '', sessionId: 0 });
const [workspaceError, setWorkspaceError] = useState('');
const [showClosePrompt, setShowClosePrompt] = useState(false);
const [discardingWorkspace, setDiscardingWorkspace] = useState(false);
const [error, setError] = useState('');
const [readyToGenerate, setReadyToGenerate] = useState(false);
const textareaRefs = useRef({});
const selectionStateRef = useRef({});
const variableMenuRefs = useRef({});
const generationControllersRef = useRef({});
const generationRequestRef = useRef({});
const templateWorkspaceRef = useRef({ slug: '', sessionId: 0 });
useEffect(() => {
function handlePointerDown(event) {
if (!openVariableMenuKey) return;
const activeMenu = variableMenuRefs.current[openVariableMenuKey];
if (activeMenu && !activeMenu.contains(event.target)) {
setOpenVariableMenuKey('');
}
}
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [openVariableMenuKey]);
useEffect(() => {
templateWorkspaceRef.current = templateWorkspace;
}, [templateWorkspace]);
useEffect(() => () => {
Object.values(generationControllersRef.current).forEach((controller) => {
controller?.abort?.();
});
}, []);
useEffect(() => {
if (!templateWorkspace.slug) return undefined;
const previousBodyOverflow = document.body.style.overflow;
const previousBodyOverscroll = document.body.style.overscrollBehavior;
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousHtmlOverscroll = document.documentElement.style.overscrollBehavior;
document.body.style.overflow = 'hidden';
document.body.style.overscrollBehavior = 'none';
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overscrollBehavior = 'none';
return () => {
document.body.style.overflow = previousBodyOverflow;
document.body.style.overscrollBehavior = previousBodyOverscroll;
document.documentElement.style.overflow = previousHtmlOverflow;
document.documentElement.style.overscrollBehavior = previousHtmlOverscroll;
};
}, [templateWorkspace.slug]);
const loadEvents = useCallback(async () => {
setLoading(true);
try {
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/events`),
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
apiClient.get(`/api/businesses/${businessId}/templates`).catch(() => ({ data: { templates: [] } })),
]);
const templates = templatesRes.data.templates || [];
const {
nextVariants,
nextGenState,
nextTemplateStatusBySlug,
nextSelectedTemplateBySlug,
} = buildTemplateUiState(templates);
setEvents(eventsRes.data.events || []);
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
setVariants(nextVariants);
setGenState(nextGenState);
setTemplateStatusBySlug(nextTemplateStatusBySlug);
setSelectedTemplateBySlug(nextSelectedTemplateBySlug);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
} catch {
setError('Failed to load events');
} finally {
setLoading(false);
}
}, [businessId]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
async function handleAddEvent(e) {
e.preventDefault();
if (!newLabel.trim()) return;
setAddingEvent(true);
setError('');
try {
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
setNewLabel('');
setShowAddForm(false);
await loadEvents();
} catch (err) {
setError(err.response?.data?.error || 'Failed to add event');
} finally {
setAddingEvent(false);
}
}
async function handleDelete(slug) {
try {
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
await loadEvents();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete event');
}
}
function clearWorkspaceInteractionState() {
setOpenVariableMenuKey('');
setActiveCaretVariantKey('');
}
function closeTemplateWorkspace() {
clearWorkspaceInteractionState();
setShowClosePrompt(false);
setDiscardingWorkspace(false);
setWorkspaceError('');
setTemplateWorkspace({ slug: '', sessionId: 0 });
}
function getWorkspaceBaseState(slug) {
if (selectedTemplateBySlug[slug]?.selectedTemplate) return 'selected';
if ((variants[slug] || []).length > 0) return 'done';
return 'idle';
}
function openTemplateWorkspace(slug) {
const nextSessionId = Date.now();
const eventVariants = variants[slug] || [];
const selectedTemplatePreview = selectedTemplateBySlug[slug];
if (eventVariants.length === 0 && selectedTemplatePreview?.selectedTemplate) {
const selectedDraftKey = getSelectedDraftKey(slug);
setVariantDrafts((currentDrafts) => {
const existingDraft = currentDrafts[selectedDraftKey];
if (existingDraft && existingDraft.originalText === selectedTemplatePreview.selectedTemplate) {
return currentDrafts;
}
return {
...currentDrafts,
[selectedDraftKey]: createVariantDraft(selectedTemplatePreview.selectedTemplate),
};
});
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: selectedDraftKey }));
}
clearWorkspaceInteractionState();
setShowClosePrompt(false);
setDiscardingWorkspace(false);
setWorkspaceError('');
setTemplateWorkspace({ slug, sessionId: nextSessionId });
return nextSessionId;
}
function resetDraftToOriginal(slug, draftKey) {
if (!draftKey) return;
setVariantDrafts((currentDrafts) => {
const existingDraft = currentDrafts[draftKey];
const originalText = existingDraft?.originalText || resolveDraftBaseText(slug, draftKey, variants, selectedTemplateBySlug);
return {
...currentDrafts,
[draftKey]: createVariantDraft(originalText),
};
});
}
function requestCloseTemplateWorkspace() {
const { slug } = templateWorkspaceRef.current;
if (!slug) return;
const isLoading = genState[slug] === 'loading';
const hasGeneratedVariants = (variants[slug] || []).length > 0;
const activeDraftKey = activeDraftKeyBySlug[slug] || '';
const activeDraft = activeDraftKey ? variantDrafts[activeDraftKey] : null;
const hasUnsavedChanges = !!activeDraft && activeDraft.currentText !== activeDraft.originalText;
if (isLoading) {
generationControllersRef.current[slug]?.abort();
delete generationControllersRef.current[slug];
const activeRequest = generationRequestRef.current[slug];
delete generationRequestRef.current[slug];
setGenState((currentState) => ({
...currentState,
[slug]: activeRequest?.previousState || getWorkspaceBaseState(slug),
}));
closeTemplateWorkspace();
return;
}
if (!hasGeneratedVariants && !hasUnsavedChanges) {
closeTemplateWorkspace();
return;
}
setShowClosePrompt(true);
}
function keepTemplateWorkspace() {
closeTemplateWorkspace();
}
async function discardTemplateWorkspace() {
const { slug } = templateWorkspaceRef.current;
if (!slug) return;
const hasGeneratedVariants = (variants[slug] || []).length > 0;
const activeDraftKey = activeDraftKeyBySlug[slug] || '';
const activeDraft = activeDraftKey ? variantDrafts[activeDraftKey] : null;
const hasUnsavedChanges = !!activeDraft && activeDraft.currentText !== activeDraft.originalText;
setDiscardingWorkspace(true);
setWorkspaceError('');
try {
if (hasGeneratedVariants) {
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/discard`);
const preservedTemplatePreview = buildSelectedTemplatePreview(res.data?.template);
const preservedStatus = preservedTemplatePreview?.status || '';
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: '' }));
setGenState((currentState) => ({
...currentState,
[slug]: preservedTemplatePreview ? 'selected' : 'idle',
}));
setSelectedTemplateBySlug((currentTemplates) => {
const nextTemplates = { ...currentTemplates };
if (preservedTemplatePreview) nextTemplates[slug] = preservedTemplatePreview;
else delete nextTemplates[slug];
return nextTemplates;
});
setTemplateStatusBySlug((currentStatuses) => {
const nextStatuses = { ...currentStatuses };
if (preservedTemplatePreview) nextStatuses[slug] = preservedStatus;
else delete nextStatuses[slug];
return nextStatuses;
});
} else if (hasUnsavedChanges) {
resetDraftToOriginal(slug, activeDraftKey);
}
closeTemplateWorkspace();
} catch (err) {
setWorkspaceError(err.response?.data?.error || 'Failed to discard template workspace');
setDiscardingWorkspace(false);
}
}
async function handleGenerate(slug, { sessionId } = {}) {
if (!readyToGenerate) {
setError('Configure and activate a cURL profile before generating templates.');
return;
}
const requestId = `${Date.now()}-${Math.random()}`;
const controller = new AbortController();
const previousState = getWorkspaceBaseState(slug);
generationControllersRef.current[slug]?.abort();
generationControllersRef.current[slug] = controller;
generationRequestRef.current[slug] = { requestId, sessionId, previousState };
setGenState((state) => ({ ...state, [slug]: 'loading' }));
setError('');
setWorkspaceError('');
setShowClosePrompt(false);
setDiscardingWorkspace(false);
try {
const res = await apiClient.post(
`/api/businesses/${businessId}/events/${slug}/generate`,
{},
{ signal: controller.signal }
);
const generatedVariants = res.data.variants || [];
if (generationRequestRef.current[slug]?.requestId !== requestId) return;
if (sessionId && (
templateWorkspaceRef.current.slug !== slug
|| templateWorkspaceRef.current.sessionId !== sessionId
)) {
return;
}
setVariants((currentVariants) => ({ ...currentVariants, [slug]: generatedVariants }));
setVariantDrafts((currentDrafts) => ({
...removeDraftsForSlug(currentDrafts, slug),
...buildDraftsForVariants(slug, generatedVariants),
}));
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: '' }));
setOpenVariableMenuKey('');
setActiveCaretVariantKey('');
setGenState((state) => ({ ...state, [slug]: 'done' }));
} catch (err) {
if (axios.isCancel(err) || err.code === 'ERR_CANCELED') return;
if (generationRequestRef.current[slug]?.requestId !== requestId) return;
if (sessionId && (
templateWorkspaceRef.current.slug !== slug
|| templateWorkspaceRef.current.sessionId !== sessionId
)) {
return;
}
setWorkspaceError(err.response?.data?.error || 'Generation failed');
setGenState((state) => ({ ...state, [slug]: 'error' }));
} finally {
if (generationRequestRef.current[slug]?.requestId === requestId) {
delete generationControllersRef.current[slug];
delete generationRequestRef.current[slug];
}
}
}
function handleOpenTemplateWorkspace(slug) {
openTemplateWorkspace(slug);
}
function handleOpenGenerateWorkspace(slug) {
const sessionId = openTemplateWorkspace(slug);
handleGenerate(slug, { sessionId });
}
function handleChooseDraft(slug, draftKey) {
const currentDraftKey = activeDraftKeyBySlug[slug] || '';
const currentDraft = currentDraftKey ? variantDrafts[currentDraftKey] : null;
const hasUnsavedChanges = currentDraftKey
&& currentDraftKey !== draftKey
&& currentDraft
&& currentDraft.currentText !== currentDraft.originalText;
if (hasUnsavedChanges && !window.confirm('Discard changes to this draft?')) {
return;
}
if (hasUnsavedChanges) {
resetDraftToOriginal(slug, currentDraftKey);
}
clearWorkspaceInteractionState();
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: draftKey }));
setWorkspaceError('');
}
async function handleValidateEdit(slug, draftKey) {
const draft = variantDrafts[draftKey];
const editedTemplate = draft?.currentText || '';
if (!editedTemplate) return;
setVariantDrafts((currentDrafts) => ({
...currentDrafts,
[draftKey]: {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: 'checking',
why: '',
lastCheckedText: '',
},
}));
setWorkspaceError('');
try {
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/validate-edit`, {
editedTemplate,
});
setVariantDrafts((currentDrafts) => ({
...currentDrafts,
[draftKey]: {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: res.data?.approved ? 'approved' : 'rejected',
why: res.data?.why || '',
lastCheckedText: editedTemplate,
},
}));
} catch (err) {
setWorkspaceError(err.response?.data?.error || 'Failed to validate edited template');
setVariantDrafts((currentDrafts) => ({
...currentDrafts,
[draftKey]: {
...(currentDrafts[draftKey] || createVariantDraft(editedTemplate)),
validationStatus: 'idle',
why: '',
lastCheckedText: '',
},
}));
}
}
async function handleSelect(slug, draftKey) {
const draft = variantDrafts[draftKey];
const selectedVariant = draft?.currentText || '';
if (!selectedVariant) return;
setSelectingVariantKey(draftKey);
setWorkspaceError('');
try {
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant });
const selectedTemplatePreview = buildSelectedTemplatePreview(res.data);
await refreshOnboardingState(businessId).catch(() => null);
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
setVariantDrafts((currentDrafts) => ({
...removeDraftsForSlug(currentDrafts, slug),
[getSelectedDraftKey(slug)]: createVariantDraft(selectedTemplatePreview?.selectedTemplate || selectedVariant),
}));
setActiveDraftKeyBySlug((currentKeys) => ({ ...currentKeys, [slug]: getSelectedDraftKey(slug) }));
setOpenVariableMenuKey('');
setActiveCaretVariantKey('');
setGenState((state) => ({ ...state, [slug]: 'selected' }));
setTemplateStatusBySlug((currentStatuses) => ({
...currentStatuses,
[slug]: normalizeTemplateStatus(res.data?.status),
}));
setSelectedTemplateBySlug((currentTemplates) => ({
...currentTemplates,
[slug]: selectedTemplatePreview,
}));
closeTemplateWorkspace();
} catch (err) {
setWorkspaceError(err.response?.data?.error || 'Failed to select template');
} finally {
setSelectingVariantKey('');
}
}
function handleVariantChange(slug, draftKey, nextText) {
const originalText = variantDrafts[draftKey]?.originalText || resolveDraftBaseText(slug, draftKey, variants, selectedTemplateBySlug);
setVariantDrafts((currentDrafts) => ({
...currentDrafts,
[draftKey]: {
originalText,
currentText: nextText,
validationStatus: 'idle',
why: '',
lastCheckedText: '',
},
}));
}
function handleRevertVariant(slug, draftKey) {
resetDraftToOriginal(slug, draftKey);
setOpenVariableMenuKey('');
}
function trackTextareaSelection(draftKey, target) {
selectionStateRef.current[draftKey] = {
start: target.selectionStart ?? 0,
end: target.selectionEnd ?? 0,
};
setActiveCaretVariantKey(draftKey);
}
function handleVariableMenuToggle(draftKey) {
setOpenVariableMenuKey((currentKey) => currentKey === draftKey ? '' : draftKey);
}
function insertVariableToken(slug, draftKey, token) {
const draft = variantDrafts[draftKey] || createVariantDraft(resolveDraftBaseText(slug, draftKey, variants, selectedTemplateBySlug));
const textarea = textareaRefs.current[draftKey];
const selection = selectionStateRef.current[draftKey];
if (!textarea || !selection) return;
const start = selection.start ?? 0;
const end = selection.end ?? start;
const nextText = `${draft.currentText.slice(0, start)}${token}${draft.currentText.slice(end)}`;
handleVariantChange(slug, draftKey, nextText);
setOpenVariableMenuKey('');
requestAnimationFrame(() => {
const nextCaretPosition = start + token.length;
textarea.focus();
textarea.setSelectionRange(nextCaretPosition, nextCaretPosition);
selectionStateRef.current[draftKey] = {
start: nextCaretPosition,
end: nextCaretPosition,
};
setActiveCaretVariantKey(draftKey);
});
}
function handleRegenerate(slug) {
const activeDraftKey = activeDraftKeyBySlug[slug] || '';
const activeDraft = activeDraftKey ? variantDrafts[activeDraftKey] : null;
const hasUnsavedChanges = !!activeDraft && activeDraft.currentText !== activeDraft.originalText;
if (hasUnsavedChanges && !window.confirm('Discard this draft and generate new options?')) {
return;
}
const sessionId = templateWorkspaceRef.current.slug === slug
? templateWorkspaceRef.current.sessionId
: openTemplateWorkspace(slug);
handleGenerate(slug, { sessionId });
}
function toggleGroup(groupId) {
setExpandedGroups((current) => ({
...current,
[groupId]: !current[groupId],
}));
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-gray-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);
const workspaceSlug = templateWorkspace.slug;
const workspaceEvent = workspaceSlug ? events.find((event) => event.slug === workspaceSlug) : null;
const workspaceVariants = workspaceSlug ? (variants[workspaceSlug] || []) : [];
const workspaceSelectedTemplatePreview = workspaceSlug ? (selectedTemplateBySlug[workspaceSlug] || null) : null;
const workspaceTemplateStatus = workspaceSlug ? (templateStatusBySlug[workspaceSlug] || 'unselected') : 'unselected';
const workspaceStatusConfig = EVENT_TEMPLATE_STATUS_CONFIG[workspaceTemplateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
const workspaceActiveDraftKey = workspaceSlug ? (activeDraftKeyBySlug[workspaceSlug] || '') : '';
const workspaceDraft = workspaceSlug && workspaceActiveDraftKey
? (variantDrafts[workspaceActiveDraftKey] || createVariantDraft(resolveDraftBaseText(workspaceSlug, workspaceActiveDraftKey, variants, selectedTemplateBySlug)))
: null;
const workspaceHasGeneratedVariants = workspaceVariants.length > 0;
const workspaceHasUnsavedChanges = !!workspaceDraft && workspaceDraft.currentText !== workspaceDraft.originalText;
const workspaceClosePromptTitle = workspaceHasGeneratedVariants
? 'Keep these templates?'
: 'Keep this draft?';
const workspaceClosePromptDescription = workspaceHasGeneratedVariants
? (workspaceHasUnsavedChanges
? 'Keep closes this workspace and leaves the generated options and your draft here for later. Discard removes these options and your unsaved edits.'
: 'Keep closes this workspace and leaves the generated options here for later. Discard removes them.')
: 'Keep closes this workspace and preserves your draft. Discard removes your unsaved edits.';
return (
<>
<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-800 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-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 && (
<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-gray-200 bg-white px-3 py-2 text-xs font-semibold text-primary-dark">
{totalVisibleEvents} visible
</span>
<button
onClick={() => setShowAddForm((visible) => !visible)}
className="px-4 py-2 rounded-lg bg-white border border-gray-300 text-sm text-gray-700 font-semibold hover:bg-gray-50 hover:border-gray-400 transition"
>
{showAddForm ? 'Cancel' : '+ Add Event'}
</button>
</div>
</div>
</div>
{!readyToGenerate && (
<div className="mb-6 px-4 py-2 rounded-lg bg-white border border-gray-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-2 rounded-md bg-white border border-gray-200 text-gray-700 font-medium text-sm flex items-center justify-between">
{error}
<button onClick={() => setError('')} className="text-gray-600 hover:text-gray-700 font-bold">&times;</button>
</div>
)}
{showAddForm && (
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-lg bg-white border border-gray-200 ">
<input
value={newLabel}
onChange={(e) => 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
/>
<button
type="submit"
disabled={addingEvent || !newLabel.trim()}
className="px-6 py-2 rounded-lg bg-primary-blue hover:bg-primary-dark text-white text-sm font-medium transition disabled:opacity-50"
>
{addingEvent ? 'Adding…' : 'Add'}
</button>
</form>
)}
{groupedEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-12 text-center ">
<p className="text-base font-semibold text-gray-800">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];
const groupStyle = EVENT_GROUP_STYLE_CONFIG[group.id] || EVENT_GROUP_STYLE_CONFIG.custom;
return (
<section key={group.id} className="overflow-hidden rounded-lg border border-gray-200 bg-white ">
<button
type="button"
onClick={() => toggleGroup(group.id)}
className="group 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-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border ${groupStyle.markerShell}`}>
<span className={`h-2.5 w-2.5 rounded-full ${groupStyle.markerDot}`} />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-800">{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-gray-50 text-gray-600 shadow-sm transition group-hover:border-gray-300 group-hover:bg-white group-hover:text-gray-800 ${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-white 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 selectedTemplatePreview = selectedTemplateBySlug[event.slug] || null;
const canViewTemplate = templateStatus !== 'unselected';
const hasWorkspaceContent = eventVariants.length > 0 || !!selectedTemplatePreview;
return (
<div key={event.slug} className="rounded-lg bg-white border border-gray-200 overflow-hidden">
<div className="flex flex-col gap-4 px-6 py-5 sm:flex-row sm:items-start sm:justify-between">
<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-white hover:bg-red-100 flex items-center justify-center border border-gray-200 text-gray-600 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-800 tracking-tight">{event.label}</h3>
{selectedTemplatePreview && (
<div className="mt-3 max-w-2xl rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Selected Template</p>
<p className="mt-2 text-sm leading-relaxed text-gray-700 line-clamp-3 break-words">
{selectedTemplatePreview.selectedTemplate}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{selectedTemplatePreview.templateId ? (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-600">
Template ID {selectedTemplatePreview.templateId}
</span>
) : (
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-gray-500">
Template ID pending
</span>
)}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<span
title={statusConfig.label}
aria-label={statusConfig.label}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold ${statusConfig.badge}`}
>
<span className={`h-2 w-2 rounded-full ${statusConfig.dot}`} />
{statusConfig.label}
</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 hover:border-gray-400 transition "
>
View in Templates
</button>
)}
<button
type="button"
onClick={() => (hasWorkspaceContent ? handleOpenTemplateWorkspace(event.slug) : handleOpenGenerateWorkspace(event.slug))}
disabled={state === 'loading' || (!hasWorkspaceContent && !readyToGenerate)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2 disabled:opacity-50 ${hasWorkspaceContent
? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 hover:border-gray-400'
: 'bg-white border border-gray-200 text-primary-dark hover:border-indigo-200 hover:bg-indigo-50'
}`}
>
{state === 'loading' ? (
<><span className="w-4 h-4 border-2 border-primary-blue border-t-indigo-600 rounded-full animate-spin" /> Generating</>
) : hasWorkspaceContent ? (
<>Open Template</>
) : (
<>Generate Template</>
)}
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</section>
);
})}
</div>
)}
</div>
{workspaceEvent && (
<TemplateWorkspaceModal
eventSlug={workspaceEvent.slug}
eventLabel={workspaceEvent.label}
statusConfig={workspaceTemplateStatus === 'unselected' ? null : workspaceStatusConfig}
loading={genState[workspaceSlug] === 'loading'}
generatedVariants={workspaceVariants}
selectedTemplatePreview={workspaceSelectedTemplatePreview}
activeDraftKey={workspaceActiveDraftKey}
activeDraft={workspaceDraft}
openVariableMenuKey={openVariableMenuKey}
onClose={requestCloseTemplateWorkspace}
onChooseVariant={(index) => handleChooseDraft(workspaceSlug, getVariantKey(workspaceSlug, index))}
onDraftChange={(nextText) => handleVariantChange(workspaceSlug, workspaceActiveDraftKey, nextText)}
onTrackSelection={(target) => trackTextareaSelection(workspaceActiveDraftKey, target)}
onToggleVariableMenu={() => handleVariableMenuToggle(workspaceActiveDraftKey)}
onInsertVariable={(token) => insertVariableToken(workspaceSlug, workspaceActiveDraftKey, token)}
onRevert={() => handleRevertVariant(workspaceSlug, workspaceActiveDraftKey)}
onCheck={() => handleValidateEdit(workspaceSlug, workspaceActiveDraftKey)}
onSelect={() => handleSelect(workspaceSlug, workspaceActiveDraftKey)}
onRegenerate={() => handleRegenerate(workspaceSlug)}
setVariableMenuRef={(node) => {
if (!workspaceActiveDraftKey) return;
if (node) variableMenuRefs.current[workspaceActiveDraftKey] = node;
else delete variableMenuRefs.current[workspaceActiveDraftKey];
}}
setTextareaRef={(node) => {
if (!workspaceActiveDraftKey) return;
if (node) textareaRefs.current[workspaceActiveDraftKey] = node;
else delete textareaRefs.current[workspaceActiveDraftKey];
}}
workspaceError={workspaceError}
selectingDraftKey={selectingVariantKey}
showClosePrompt={showClosePrompt}
closePromptTitle={workspaceClosePromptTitle}
closePromptDescription={workspaceClosePromptDescription}
discardingWorkspace={discardingWorkspace}
onKeepWorkspace={keepTemplateWorkspace}
onDiscardWorkspace={discardTemplateWorkspace}
/>
)}
</>
);
}