variable entering popups in editing template
This commit is contained in:
parent
73d0a08cd9
commit
b34915c58b
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -405,37 +490,87 @@ export default function Events() {
|
|||
: '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…
|
||||
<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 === '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 === '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 === '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>
|
||||
)}
|
||||
{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
|
||||
|
|
@ -445,13 +580,18 @@ export default function Events() {
|
|||
/>
|
||||
|
||||
<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>
|
||||
<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
|
||||
|
|
@ -470,10 +610,16 @@ export default function Events() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{placeholderMismatch && (
|
||||
{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">
|
||||
Keep the same number of <span className="font-mono">{PLACEHOLDER_TOKEN}</span> placeholders.
|
||||
This draft has {placeholderCount}, while the generated version has {originalPlaceholderCount}.
|
||||
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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ async function processCurl(rawCurl, approvedTemplate, eventSlug) {
|
|||
raw_curl: String(rawCurl || ''),
|
||||
approved_template: String(approvedTemplate || ''),
|
||||
event_slug: String(eventSlug || ''),
|
||||
instructions_text: 'Identify placeholders, map to semantic field names, normalize placeholders in curl to camelCase, and build positional mapping for {#var#} tokens in approved_template.',
|
||||
output_schema_text: 'Return ONLY valid JSON object with exactly these keys: processedCurl (string), variableMap (object where keys are {#var#}[index] and values are field names in camelCase). No extra keys.',
|
||||
instructions_text: 'Identify placeholders, map to semantic field names, normalize placeholders in curl to camelCase, and build positional mapping for DLT placeholder tokens in approved_template. Supported token types include {#var#}, {#numeric#}, {#url#}, and {#cbn#}. Preserve the actual token text in variableMap keys using the format "<token>[index]" based on appearance order within approved_template.',
|
||||
output_schema_text: 'Return ONLY valid JSON object with exactly these keys: processedCurl (string), variableMap (object where keys preserve the actual DLT token text in approved_template, such as {#var#}[0], {#numeric#}[1], {#url#}[2], and values are field names in camelCase). No extra keys.',
|
||||
must_return_json_only: 'true',
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user