Checking post edits on generated template
This commit is contained in:
parent
2f9f469be8
commit
82cc095b6e
|
|
@ -2,6 +2,81 @@ import { useState, useEffect, useCallback } 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 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() {
|
export default function Events() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
|
|
@ -11,6 +86,7 @@ export default function Events() {
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [genState, setGenState] = useState({});
|
const [genState, setGenState] = useState({});
|
||||||
const [variants, setVariants] = useState({});
|
const [variants, setVariants] = useState({});
|
||||||
|
const [variantDrafts, setVariantDrafts] = useState({});
|
||||||
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||||
|
|
@ -18,12 +94,20 @@ export default function Events() {
|
||||||
const loadEvents = useCallback(async () => {
|
const loadEvents = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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}/events`),
|
||||||
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
|
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 || []);
|
setEvents(eventsRes.data.events || []);
|
||||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||||
|
setVariants(nextVariants);
|
||||||
|
setGenState(nextGenState);
|
||||||
|
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load events');
|
setError('Failed to load events');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -66,27 +150,82 @@ export default function Events() {
|
||||||
setError('Configure and activate a cURL profile before generating templates.');
|
setError('Configure and activate a cURL profile before generating templates.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGenState(s => ({ ...s, [slug]: 'loading' }));
|
|
||||||
|
setGenState((state) => ({ ...state, [slug]: 'loading' }));
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`);
|
const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`);
|
||||||
setVariants(v => ({ ...v, [slug]: res.data.variants }));
|
const generatedVariants = res.data.variants || [];
|
||||||
setGenState(s => ({ ...s, [slug]: 'done' }));
|
|
||||||
|
setVariants((currentVariants) => ({ ...currentVariants, [slug]: generatedVariants }));
|
||||||
|
setVariantDrafts((currentDrafts) => ({
|
||||||
|
...removeDraftsForSlug(currentDrafts, slug),
|
||||||
|
...buildDraftsForVariants(slug, generatedVariants),
|
||||||
|
}));
|
||||||
|
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Generation failed');
|
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) {
|
async function handleSelect(slug, variant, variantIndex) {
|
||||||
const variantKey = `${slug}:${variantIndex}`;
|
const variantKey = getVariantKey(slug, variantIndex);
|
||||||
setSelectingVariantKey(variantKey);
|
setSelectingVariantKey(variantKey);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
|
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
|
||||||
// Clear variants display after selection
|
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
||||||
setVariants(v => ({ ...v, [slug]: [] }));
|
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
||||||
setGenState(s => ({ ...s, [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');
|
||||||
} finally {
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|
@ -104,21 +269,19 @@ export default function Events() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between pb-5 mb-6 border-b border-gray-200">
|
<div className="flex items-center justify-between pb-5 mb-6 border-b border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Events</h1>
|
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Events</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
|
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddForm(v => !v)}
|
onClick={() => setShowAddForm((visible) => !visible)}
|
||||||
className="px-4 py-2 rounded-lg bg-white border border-gray-300 shadow-sm text-sm text-gray-700 font-semibold hover:bg-gray-50 transition"
|
className="px-4 py-2 rounded-lg bg-white border border-gray-300 shadow-sm text-sm text-gray-700 font-semibold hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
{showAddForm ? 'Cancel' : '+ Add Event'}
|
{showAddForm ? 'Cancel' : '+ Add Event'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generation readiness banner */}
|
|
||||||
{!readyToGenerate && (
|
{!readyToGenerate && (
|
||||||
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2">
|
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2">
|
||||||
<span>⚠️</span>
|
<span>⚠️</span>
|
||||||
|
|
@ -133,12 +296,11 @@ export default function Events() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Event form */}
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-xl bg-gray-50 border border-gray-200 shadow-sm">
|
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-xl bg-gray-50 border border-gray-200 shadow-sm">
|
||||||
<input
|
<input
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
onChange={e => setNewLabel(e.target.value)}
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
placeholder="Event name (e.g. Return Initiated)"
|
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"
|
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
|
autoFocus
|
||||||
|
|
@ -153,20 +315,18 @@ export default function Events() {
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Events list */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{events.map(event => {
|
{events.map((event) => {
|
||||||
const state = genState[event.slug] || 'idle';
|
const state = genState[event.slug] || 'idle';
|
||||||
const eventVariants = variants[event.slug] || [];
|
const eventVariants = variants[event.slug] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||||
{/* Event header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{event.isDefault ? (
|
{event.isDefault ? (
|
||||||
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
||||||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|
@ -209,39 +369,168 @@ export default function Events() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generated variants */}
|
|
||||||
{eventVariants.length > 0 && (
|
{eventVariants.length > 0 && (
|
||||||
<div className="border-t border-gray-100 bg-gray-50/50 px-6 py-5 space-y-4">
|
<div className="border-t border-gray-100 bg-gray-50/50 px-6 py-5 space-y-4">
|
||||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Pick a Variant</p>
|
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{eventVariants.map((v, i) => {
|
{eventVariants.map((variant, index) => {
|
||||||
const variantKey = `${event.slug}:${i}`;
|
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 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 canUseEdited = isEdited
|
||||||
|
&& validationStatus === 'approved'
|
||||||
|
&& currentMatchesCheckedText
|
||||||
|
&& !tooLong
|
||||||
|
&& !placeholderMismatch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={variantKey}
|
||||||
className={`rounded-xl border bg-white p-5 shadow-sm transition ${
|
className={`rounded-xl border bg-white p-5 shadow-sm transition ${
|
||||||
isSelectingThis
|
isSelectingThis
|
||||||
? 'border-indigo-300 ring-2 ring-indigo-100'
|
? 'border-indigo-300 ring-2 ring-indigo-100'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-gray-800 font-mono leading-relaxed">{v}</p>
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
<div className="flex items-center justify-between mt-4">
|
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
|
||||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md ${v.length > 160 ? 'bg-red-50 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
isEdited
|
||||||
{v.length} / 160
|
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{isEdited ? 'Edited Draft' : 'Original Draft'}
|
||||||
</span>
|
</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 === '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>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={currentText}
|
||||||
|
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
|
||||||
|
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
|
||||||
|
? 'border-amber-200 bg-amber-50/40 focus:ring-amber-200 focus:border-amber-300'
|
||||||
|
: 'border-gray-200 bg-gray-50 focus:ring-indigo-100 focus:border-indigo-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{isEdited && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelect(event.slug, v, i)}
|
onClick={() => handleRevertVariant(event.slug, index)}
|
||||||
|
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50 transition"
|
||||||
|
>
|
||||||
|
Revert to original
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEdited && (
|
||||||
|
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p>
|
||||||
|
<p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{placeholderMismatch && (
|
||||||
|
<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}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tooLong && (
|
||||||
|
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && (
|
||||||
|
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<span className="font-semibold">Why it did not pass:</span> {draft.why}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||||
|
{!isEdited ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(event.slug, currentText, index)}
|
||||||
disabled={isSelectingThis || isSelectingAnotherVariant}
|
disabled={isSelectingThis || isSelectingAnotherVariant}
|
||||||
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isSelectingThis ? 'Selecting…' : 'Use this template'}
|
{isSelectingThis ? 'Selecting…' : 'Use this template'}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(event.slug, originalText, index)}
|
||||||
|
disabled={isSelectingThis || isSelectingAnotherVariant}
|
||||||
|
className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-gray-50 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSelectingThis ? 'Selecting…' : 'Use original'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canUseEdited ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(event.slug, currentText, index)}
|
||||||
|
disabled={isSelectingThis || isSelectingAnotherVariant}
|
||||||
|
className="text-xs px-4 py-2 rounded-md bg-green-600 hover:bg-green-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSelectingThis ? 'Selecting…' : 'Use edited version'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleValidateEdit(event.slug, index)}
|
||||||
|
disabled={!canRunCheck || isSelectingThis || isSelectingAnotherVariant}
|
||||||
|
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{validationStatus === 'checking'
|
||||||
|
? 'Checking…'
|
||||||
|
: validationStatus === 'rejected' && currentMatchesCheckedText
|
||||||
|
? 'Check again'
|
||||||
|
: 'Check edit'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,20 @@ function getShipmentToNumber(body) {
|
||||||
return normalizeText(body?.payload?.shipment?.user?.mobile);
|
return normalizeText(body?.payload?.shipment?.user?.mobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseWorkflowPayload(data) {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const trimmed = data.trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return { status: trimmed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data && typeof data === 'object' ? data : {};
|
||||||
|
}
|
||||||
|
|
||||||
async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }) {
|
async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }) {
|
||||||
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
|
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
|
||||||
if (!workflowUrl) {
|
if (!workflowUrl) {
|
||||||
|
|
@ -230,6 +244,61 @@ async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEditedTemplateValidation(data) {
|
||||||
|
const payload = parseWorkflowPayload(data);
|
||||||
|
|
||||||
|
let approved = null;
|
||||||
|
if (typeof payload.approved === 'boolean') approved = payload.approved;
|
||||||
|
if (approved === null && typeof payload.isApproved === 'boolean') approved = payload.isApproved;
|
||||||
|
if (approved === null && typeof payload.valid === 'boolean') approved = payload.valid;
|
||||||
|
if (approved === null && typeof payload.is_valid === 'boolean') approved = payload.is_valid;
|
||||||
|
|
||||||
|
if (approved === null) {
|
||||||
|
const status = normalizeText(payload.status || payload.result || payload.decision).toLowerCase();
|
||||||
|
if (['approved', 'pass', 'passed', 'valid', 'ok'].includes(status)) approved = true;
|
||||||
|
if (['rejected', 'not_approved', 'failed', 'fail', 'invalid', 'needs_changes'].includes(status)) approved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approved === null) {
|
||||||
|
throw createHttpError(502, 'Template edit validation workflow returned an unreadable response', {
|
||||||
|
details: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
approved,
|
||||||
|
why: normalizeText(payload.why || payload.reason || payload.message || payload.feedback),
|
||||||
|
workflowResult: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateEditedTemplateWorkflow(editedTemplate) {
|
||||||
|
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_TEMPLATE_EDIT_CHECK);
|
||||||
|
if (!workflowUrl) {
|
||||||
|
throw createHttpError(500, 'WORKFLOW_URL_TEMPLATE_EDIT_CHECK is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
workflowUrl,
|
||||||
|
{ editedTemplate },
|
||||||
|
{
|
||||||
|
timeout: 30000,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
validateStatus: () => true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
throw createHttpError(
|
||||||
|
502,
|
||||||
|
`Template edit validation workflow failed with status ${response.status}`,
|
||||||
|
{ details: response.data }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeEditedTemplateValidation(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
function getProviderPatch(input) {
|
function getProviderPatch(input) {
|
||||||
if (!input || typeof input !== 'object') return null;
|
if (!input || typeof input !== 'object') return null;
|
||||||
|
|
||||||
|
|
@ -930,6 +999,26 @@ router.get('/:businessId/templates/:slug', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/businesses/:businessId/templates/:slug/validate-edit
|
||||||
|
router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { businessId, slug } = req.params;
|
||||||
|
const editedTemplate = normalizeText(req.body?.editedTemplate);
|
||||||
|
if (!editedTemplate) {
|
||||||
|
return res.status(400).json({ error: 'editedTemplate is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
|
||||||
|
const tmpl = await fetchJSON(folder, slug);
|
||||||
|
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
|
||||||
|
const validation = await validateEditedTemplateWorkflow(editedTemplate);
|
||||||
|
res.json(validation);
|
||||||
|
} catch (err) {
|
||||||
|
sendRouteError(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/businesses/:businessId/templates/:slug/select
|
// POST /api/businesses/:businessId/templates/:slug/select
|
||||||
router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user