variable entering popups in editing template

This commit is contained in:
Ritul Jadhav 2026-03-30 15:12:46 +05:30
parent 73d0a08cd9
commit b34915c58b
2 changed files with 191 additions and 45 deletions

View File

@ -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,37 +490,87 @@ 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">
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${ <div className="flex flex-wrap items-center gap-2">
isEdited <span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
? 'bg-amber-50 border-amber-200 text-amber-700' isEdited
: 'bg-gray-50 border-gray-200 text-gray-600' ? 'bg-amber-50 border-amber-200 text-amber-700'
}`}> : 'bg-gray-50 border-gray-200 text-gray-600'
{isEdited ? 'Edited Draft' : 'Original Draft'} }`}>
</span> {isEdited ? 'Edited Draft' : 'Original Draft'}
{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> </span>
)}
{validationStatus === 'approved' && currentMatchesCheckedText && ( {validationStatus === 'checking' && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-green-50 border-green-200 text-green-700"> <span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-blue-50 border-blue-200 text-blue-700">
Edit passed check Checking edit
</span> </span>
)} )}
{validationStatus === 'rejected' && currentMatchesCheckedText && ( {validationStatus === 'approved' && currentMatchesCheckedText && (
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-red-50 border-red-200 text-red-700"> <span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-green-50 border-green-200 text-green-700">
Needs changes Edit passed check
</span> </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> </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,13 +580,18 @@ 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">
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${ <div className="flex flex-wrap items-center gap-2">
tooLong <span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
? 'bg-red-50 border-red-200 text-red-700' tooLong
: 'bg-gray-100 border-gray-200 text-gray-600' ? 'bg-red-50 border-red-200 text-red-700'
}`}> : 'bg-gray-100 border-gray-200 text-gray-600'
{currentText.length} / {MAX_SMS_LENGTH} }`}>
</span> {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 && ( {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>
)} )}

View File

@ -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',
}; };