diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index ad66558..d980c1d 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -2,6 +2,81 @@ 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([]); @@ -11,6 +86,7 @@ export default function Events() { 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); @@ -18,12 +94,20 @@ export default function Events() { const loadEvents = useCallback(async () => { setLoading(true); try { - const [eventsRes, activeProfileRes] = await Promise.all([ + 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 { @@ -66,27 +150,82 @@ export default function Events() { setError('Configure and activate a cURL profile before generating templates.'); return; } - setGenState(s => ({ ...s, [slug]: 'loading' })); + + setGenState((state) => ({ ...state, [slug]: 'loading' })); setError(''); + try { const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`); - setVariants(v => ({ ...v, [slug]: res.data.variants })); - setGenState(s => ({ ...s, [slug]: 'done' })); + 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(s => ({ ...s, [slug]: 'error' })); + 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 = `${slug}:${variantIndex}`; + const variantKey = getVariantKey(slug, variantIndex); setSelectingVariantKey(variantKey); setError(''); + try { await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); - // Clear variants display after selection - setVariants(v => ({ ...v, [slug]: [] })); - setGenState(s => ({ ...s, [slug]: 'selected' })); + 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 { @@ -94,6 +233,32 @@ export default function Events() { } } + 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 (
@@ -104,21 +269,19 @@ export default function Events() { return (
- {/* Header */}

Events

Generate SMS templates for each order event.

- {/* Generation readiness banner */} {!readyToGenerate && (
⚠️ @@ -133,12 +296,11 @@ export default function Events() {
)} - {/* Add Event form */} {showAddForm && (
setNewLabel(e.target.value)} + 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 @@ -153,20 +315,18 @@ export default function Events() {
)} - {/* Events list */}
- {events.map(event => { + {events.map((event) => { const state = genState[event.slug] || 'idle'; const eventVariants = variants[event.slug] || []; return (
- {/* Event header */}
{event.isDefault ? (
- +
) : (
- {/* Generated variants */} {eventVariants.length > 0 && (
-

Pick a Variant

+

Review, edit, and choose a variant

- {eventVariants.map((v, i) => { - const variantKey = `${event.slug}:${i}`; + {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 (
-

{v}

-
- 160 ? 'bg-red-50 text-red-700' : 'bg-gray-100 text-gray-600'}`}> - {v.length} / 160 - - +
+ + {isEdited ? 'Edited Draft' : 'Original Draft'} + + + {validationStatus === 'checking' && ( + + Checking edit… + + )} + + {validationStatus === 'approved' && currentMatchesCheckedText && ( + + Edit passed check + + )} + + {validationStatus === 'rejected' && currentMatchesCheckedText && ( + + Needs changes + + )} +
+ +