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 { useParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
const MAX_SMS_LENGTH = 160;
|
const MAX_SMS_LENGTH = 160;
|
||||||
const PLACEHOLDER_TOKEN = '{#var#}';
|
const DLT_VARIABLE_OPTIONS = [
|
||||||
const PLACEHOLDER_REGEX = /\{#var#\}/g;
|
{ 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) {
|
function getVariantKey(slug, index) {
|
||||||
return `${slug}:${index}`;
|
return `${slug}:${index}`;
|
||||||
|
|
@ -20,8 +27,21 @@ function createVariantDraft(text = '') {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function countPlaceholders(text = '') {
|
function getDltTokens(text = '') {
|
||||||
return (String(text).match(PLACEHOLDER_REGEX) || []).length;
|
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 = []) {
|
function buildDraftsForVariants(slug, generatedVariants = []) {
|
||||||
|
|
@ -88,9 +108,28 @@ export default function Events() {
|
||||||
const [variants, setVariants] = useState({});
|
const [variants, setVariants] = useState({});
|
||||||
const [variantDrafts, setVariantDrafts] = useState({});
|
const [variantDrafts, setVariantDrafts] = useState({});
|
||||||
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
||||||
|
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
|
||||||
|
const [activeCaretVariantKey, setActiveCaretVariantKey] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
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 () => {
|
const loadEvents = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -163,6 +202,8 @@ export default function Events() {
|
||||||
...removeDraftsForSlug(currentDrafts, slug),
|
...removeDraftsForSlug(currentDrafts, slug),
|
||||||
...buildDraftsForVariants(slug, generatedVariants),
|
...buildDraftsForVariants(slug, generatedVariants),
|
||||||
}));
|
}));
|
||||||
|
setOpenVariableMenuKey('');
|
||||||
|
setActiveCaretVariantKey('');
|
||||||
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Generation failed');
|
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 });
|
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
|
||||||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
||||||
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
||||||
|
setOpenVariableMenuKey('');
|
||||||
|
setActiveCaretVariantKey('');
|
||||||
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to select template');
|
setError(err.response?.data?.error || 'Failed to select template');
|
||||||
|
|
@ -257,6 +300,46 @@ export default function Events() {
|
||||||
...currentDrafts,
|
...currentDrafts,
|
||||||
[variantKey]: createVariantDraft(originalText),
|
[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) {
|
if (loading) {
|
||||||
|
|
@ -381,20 +464,22 @@ export default function Events() {
|
||||||
const validationStatus = draft.validationStatus;
|
const validationStatus = draft.validationStatus;
|
||||||
const currentMatchesCheckedText = draft.lastCheckedText === currentText;
|
const currentMatchesCheckedText = draft.lastCheckedText === currentText;
|
||||||
const isEdited = currentText !== originalText;
|
const isEdited = currentText !== originalText;
|
||||||
const placeholderCount = countPlaceholders(currentText);
|
const dltTokenCount = countDltTokens(currentText);
|
||||||
const originalPlaceholderCount = countPlaceholders(originalText);
|
const invalidDltTokens = getInvalidDltTokens(currentText);
|
||||||
const placeholderMismatch = placeholderCount !== originalPlaceholderCount;
|
const hasMalformedDltToken = hasMalformedDltFragments(currentText);
|
||||||
|
const hasInvalidPlaceholder = invalidDltTokens.length > 0 || hasMalformedDltToken;
|
||||||
const tooLong = currentText.length > MAX_SMS_LENGTH;
|
const tooLong = currentText.length > MAX_SMS_LENGTH;
|
||||||
const isSelectingThis = selectingVariantKey === variantKey;
|
const isSelectingThis = selectingVariantKey === variantKey;
|
||||||
const isSelectingAnotherVariant = !!selectingVariantKey
|
const isSelectingAnotherVariant = !!selectingVariantKey
|
||||||
&& selectingVariantKey !== variantKey
|
&& selectingVariantKey !== variantKey
|
||||||
&& selectingVariantKey.startsWith(`${event.slug}:`);
|
&& selectingVariantKey.startsWith(`${event.slug}:`);
|
||||||
const canRunCheck = isEdited && !tooLong && !placeholderMismatch && validationStatus !== 'checking';
|
const canRunCheck = isEdited && !tooLong && !hasInvalidPlaceholder && validationStatus !== 'checking';
|
||||||
const canUseEdited = isEdited
|
const canUseEdited = isEdited
|
||||||
&& validationStatus === 'approved'
|
&& validationStatus === 'approved'
|
||||||
&& currentMatchesCheckedText
|
&& currentMatchesCheckedText
|
||||||
&& !tooLong
|
&& !tooLong
|
||||||
&& !placeholderMismatch;
|
&& !hasInvalidPlaceholder;
|
||||||
|
const canInsertVariable = activeCaretVariantKey === variantKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -405,7 +490,8 @@ export default function Events() {
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<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 ${
|
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
|
||||||
isEdited
|
isEdited
|
||||||
? 'bg-amber-50 border-amber-200 text-amber-700'
|
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||||
|
|
@ -433,9 +519,58 @@ export default function Events() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<textarea
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) textareaRefs.current[variantKey] = node;
|
||||||
|
else delete textareaRefs.current[variantKey];
|
||||||
|
}}
|
||||||
value={currentText}
|
value={currentText}
|
||||||
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
|
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}
|
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 ${
|
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
|
isEdited
|
||||||
|
|
@ -445,6 +580,7 @@ export default function Events() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mt-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 mt-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
|
||||||
tooLong
|
tooLong
|
||||||
? 'bg-red-50 border-red-200 text-red-700'
|
? 'bg-red-50 border-red-200 text-red-700'
|
||||||
|
|
@ -452,6 +588,10 @@ export default function Events() {
|
||||||
}`}>
|
}`}>
|
||||||
{currentText.length} / {MAX_SMS_LENGTH}
|
{currentText.length} / {MAX_SMS_LENGTH}
|
||||||
</span>
|
</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 && (
|
{isEdited && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -470,10 +610,16 @@ export default function Events() {
|
||||||
</div>
|
</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">
|
<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.
|
Unsupported DLT variable token{invalidDltTokens.length > 1 ? 's' : ''}: <span className="font-mono">{invalidDltTokens.join(', ')}</span>.
|
||||||
This draft has {placeholderCount}, while the generated version has {originalPlaceholderCount}.
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,8 +115,8 @@ async function processCurl(rawCurl, approvedTemplate, eventSlug) {
|
||||||
raw_curl: String(rawCurl || ''),
|
raw_curl: String(rawCurl || ''),
|
||||||
approved_template: String(approvedTemplate || ''),
|
approved_template: String(approvedTemplate || ''),
|
||||||
event_slug: String(eventSlug || ''),
|
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.',
|
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 are {#var#}[index] and values are field names in camelCase). No extra keys.',
|
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',
|
must_return_json_only: 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user