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

548 lines
24 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 } from 'react';
import { useParams } from 'react-router-dom';
import apiClient from '../api/client';
const MAX_SMS_LENGTH = 160;
const PLACEHOLDER_TOKEN = '{#var#}';
const PLACEHOLDER_REGEX = /\{#var#\}/g;
function getVariantKey(slug, index) {
return `${slug}:${index}`;
}
function createVariantDraft(text = '') {
return {
originalText: text,
currentText: text,
validationStatus: 'idle',
why: '',
lastCheckedText: '',
};
}
function countPlaceholders(text = '') {
return (String(text).match(PLACEHOLDER_REGEX) || []).length;
}
function buildDraftsForVariants(slug, generatedVariants = []) {
const nextDrafts = {};
generatedVariants.forEach((variant, index) => {
nextDrafts[getVariantKey(slug, index)] = createVariantDraft(variant);
});
return nextDrafts;
}
function removeDraftsForSlug(drafts, slug) {
const nextDrafts = { ...drafts };
Object.keys(nextDrafts).forEach((key) => {
if (key.startsWith(`${slug}:`)) delete nextDrafts[key];
});
return nextDrafts;
}
function syncDraftsWithVariants(existingDrafts, variantsBySlug) {
const nextDrafts = {};
Object.entries(variantsBySlug).forEach(([slug, generatedVariants]) => {
generatedVariants.forEach((variant, index) => {
const key = getVariantKey(slug, index);
const existing = existingDrafts[key];
nextDrafts[key] = existing && existing.originalText === variant
? existing
: createVariantDraft(variant);
});
});
return nextDrafts;
}
function buildTemplateUiState(templates = []) {
const nextVariants = {};
const nextGenState = {};
templates.forEach((template) => {
if (!template?.eventSlug) return;
if (template.selectedTemplate) {
nextGenState[template.eventSlug] = 'selected';
return;
}
if (Array.isArray(template.generatedVariants) && template.generatedVariants.length > 0) {
nextVariants[template.eventSlug] = template.generatedVariants;
nextGenState[template.eventSlug] = 'done';
}
});
return { nextVariants, nextGenState };
}
export default function Events() {
const { businessId } = useParams();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [newLabel, setNewLabel] = useState('');
const [addingEvent, setAddingEvent] = useState(false);
const [showAddForm, setShowAddForm] = useState(false);
const [genState, setGenState] = useState({});
const [variants, setVariants] = useState({});
const [variantDrafts, setVariantDrafts] = useState({});
const [selectingVariantKey, setSelectingVariantKey] = useState('');
const [error, setError] = useState('');
const [readyToGenerate, setReadyToGenerate] = useState(false);
const loadEvents = useCallback(async () => {
setLoading(true);
try {
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}/events`),
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
apiClient.get(`/api/businesses/${businessId}/templates`).catch(() => ({ data: { templates: [] } })),
]);
const templates = templatesRes.data.templates || [];
const { nextVariants, nextGenState } = buildTemplateUiState(templates);
setEvents(eventsRes.data.events || []);
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
setVariants(nextVariants);
setGenState(nextGenState);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
} catch {
setError('Failed to load events');
} finally {
setLoading(false);
}
}, [businessId]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
async function handleAddEvent(e) {
e.preventDefault();
if (!newLabel.trim()) return;
setAddingEvent(true);
setError('');
try {
await apiClient.post(`/api/businesses/${businessId}/events`, { label: newLabel.trim() });
setNewLabel('');
setShowAddForm(false);
await loadEvents();
} catch (err) {
setError(err.response?.data?.error || 'Failed to add event');
} finally {
setAddingEvent(false);
}
}
async function handleDelete(slug) {
try {
await apiClient.delete(`/api/businesses/${businessId}/events/${slug}`);
await loadEvents();
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete event');
}
}
async function handleGenerate(slug) {
if (!readyToGenerate) {
setError('Configure and activate a cURL profile before generating templates.');
return;
}
setGenState((state) => ({ ...state, [slug]: 'loading' }));
setError('');
try {
const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`);
const generatedVariants = res.data.variants || [];
setVariants((currentVariants) => ({ ...currentVariants, [slug]: generatedVariants }));
setVariantDrafts((currentDrafts) => ({
...removeDraftsForSlug(currentDrafts, slug),
...buildDraftsForVariants(slug, generatedVariants),
}));
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);
setSelectingVariantKey(variantKey);
setError('');
try {
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
setGenState((state) => ({ ...state, [slug]: 'selected' }));
} 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),
}));
}
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>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between 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>
<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>
{!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>
)}
<div className="space-y-4">
{events.map((event) => {
const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || [];
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>
<p className="text-xs text-gray-500 font-mono mt-0.5">{event.slug}</p>
</div>
</div>
<div className="flex items-center gap-3">
{state === 'selected' && (
<span className="text-xs font-semibold px-2.5 py-1 rounded-md bg-green-50 border border-green-200 text-green-700">
Template Selected
</span>
)}
<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 placeholderCount = countPlaceholders(currentText);
const originalPlaceholderCount = countPlaceholders(originalText);
const placeholderMismatch = placeholderCount !== originalPlaceholderCount;
const tooLong = currentText.length > MAX_SMS_LENGTH;
const isSelectingThis = selectingVariantKey === variantKey;
const isSelectingAnotherVariant = !!selectingVariantKey
&& selectingVariantKey !== variantKey
&& selectingVariantKey.startsWith(`${event.slug}:`);
const canRunCheck = isEdited && !tooLong && !placeholderMismatch && validationStatus !== 'checking';
const canUseEdited = isEdited
&& validationStatus === 'approved'
&& currentMatchesCheckedText
&& !tooLong
&& !placeholderMismatch;
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-center gap-2 mb-3">
<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>
<textarea
value={currentText}
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
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">
<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>
{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>
)}
{placeholderMismatch && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Keep the same number of <span className="font-mono">{PLACEHOLDER_TOKEN}</span> placeholders.
This draft has {placeholderCount}, while the generated version has {originalPlaceholderCount}.
</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>
);
}