diff --git a/client/src/pages/Events.jsx b/client/src/pages/Events.jsx index d980c1d..d02429b 100644 --- a/client/src/pages/Events.jsx +++ b/client/src/pages/Events.jsx @@ -1,10 +1,17 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } 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; +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; function getVariantKey(slug, index) { return `${slug}:${index}`; @@ -20,8 +27,21 @@ function createVariantDraft(text = '') { }; } -function countPlaceholders(text = '') { - return (String(text).match(PLACEHOLDER_REGEX) || []).length; +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 = []) { @@ -88,9 +108,28 @@ export default function Events() { const [variants, setVariants] = useState({}); const [variantDrafts, setVariantDrafts] = useState({}); const [selectingVariantKey, setSelectingVariantKey] = useState(''); + const [openVariableMenuKey, setOpenVariableMenuKey] = useState(''); + const [activeCaretVariantKey, setActiveCaretVariantKey] = 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 { @@ -163,6 +202,8 @@ export default function Events() { ...removeDraftsForSlug(currentDrafts, slug), ...buildDraftsForVariants(slug, generatedVariants), })); + setOpenVariableMenuKey(''); + setActiveCaretVariantKey(''); setGenState((state) => ({ ...state, [slug]: 'done' })); } catch (err) { setError(err.response?.data?.error || 'Generation failed'); @@ -225,6 +266,8 @@ export default function Events() { await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] })); setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug)); + setOpenVariableMenuKey(''); + setActiveCaretVariantKey(''); setGenState((state) => ({ ...state, [slug]: 'selected' })); } catch (err) { setError(err.response?.data?.error || 'Failed to select template'); @@ -257,6 +300,46 @@ export default function Events() { ...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); + }); } if (loading) { @@ -381,20 +464,22 @@ export default function Events() { 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 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 && !placeholderMismatch && validationStatus !== 'checking'; + const canRunCheck = isEdited && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking'; const canUseEdited = isEdited && validationStatus === 'approved' && currentMatchesCheckedText && !tooLong - && !placeholderMismatch; + && !hasInvalidPlaceholder; + const canInsertVariable = activeCaretVariantKey === variantKey; return (
-
- - {isEdited ? 'Edited Draft' : 'Original Draft'} - - - {validationStatus === 'checking' && ( - - Checking edit… +
+
+ + {isEdited ? 'Edited Draft' : 'Original Draft'} - )} - {validationStatus === 'approved' && currentMatchesCheckedText && ( - - Edit passed check - - )} + {validationStatus === 'checking' && ( + + Checking edit… + + )} - {validationStatus === 'rejected' && currentMatchesCheckedText && ( - - Needs changes - - )} + {validationStatus === 'approved' && currentMatchesCheckedText && ( + + Edit passed check + + )} + + {validationStatus === 'rejected' && currentMatchesCheckedText && ( + + Needs changes + + )} +
+ +
{ + if (node) variableMenuRefs.current[variantKey] = node; + else delete variableMenuRefs.current[variantKey]; + }} + > + + + {openVariableMenuKey === variantKey && ( +
+
+

Insert DLT Variable

+
+
+ {DLT_VARIABLE_OPTIONS.map((option) => ( + + ))} +
+
+ )} +