feat(whatsapp): admin-selectable template parameters + reorder (#647 follow-up)
Reporter @Rekoo-PS confirmed the language fix unblocked sending, then
hit a second gap: their template uses only `{{1}} = event_name` +
`{{2}} = gallery_link`, but the legacy `buildComponents` hardcoded all
5 positional values from the `gallery_ready` shape (customer_name,
event_name, gallery_link, password_line, expiry_date). Meta rejected
with a parameter-count mismatch even after the language matched.
This adds a per-config slot list — which built-in values to send, and
in what positional order — so admins can match templates of any shape
without code changes.
## Schema (migration 138)
Additive `template_params` TEXT column on `whatsapp_configs` (default
empty string = legacy 5-slot behaviour for existing installs). Stored
as a JSON-serialized array of slot keys: `customer_name`, `event_name`,
`gallery_link`, `password_line`, `expiry_date`. Unknown / duplicate /
non-string entries are sanitized out at read time.
## Processor
- `parseTemplateParams(raw)` — defensive parser; falls back to the
5-slot default on empty / malformed / all-invalid input.
- `buildComponents(data, metaLang, params)` — emits ONLY the listed
slots in the listed order, computed via a small switch on slot key.
The password line still receives the locale-specific 🔒 label and
the empty-when-no-real-password sentinel handling.
- Processor reads `config.template_params` once per cycle and passes
the parsed array to `buildComponents` per message.
## Admin route
- GET surfaces `template_params` as the parsed array (default 5-slot
when null/empty).
- PUT round-trips the incoming array through `parseTemplateParams`
before persisting, so the stored value is always the canonical
sanitized JSON.
- Test send rebuilt to use the same `buildComponents` path so the
admin's test message matches their configured slot shape — a
reporter who configures 2 slots gets a 2-parameter test send, not
the legacy 5-parameter payload.
## UI
- `WhatsAppTab` gets a checkbox + up/down list under the Template
language field. Each slot shows its current `{{N}}` position when
checked, an em-dash when unchecked. Live preview below the list:
"Your template will receive: {{1}} = event_name, {{2}} = gallery_link".
- EN + DE i18n for the field labels, hint, preview, and per-slot
human-readable names.
## Tests
- 17 unit tests in `__tests__/utils/whatsappBuildComponents.test.js`
covering: parseTemplateParams sanitization (unknown keys, duplicates,
non-strings, malformed JSON, all-invalid fallback, pre-parsed array
acceptance) and buildComponents shape (reporter's 2-slot case,
reorder, empty list, locale-specific password label, password
sentinel handling, expiry omission).
- All 17 + the 34 existing networkValidation tests pass.
## Migration numbering
Sits at 138 on top of PR #649's migration 137. If #646 (Live Slideshow)
merges before this, #646's own 137 + 138 take precedence and this
needs renumbering to 139. Coordinated via PR #646's review thread.
## Honest caveat
Still no Meta Business API account on my side. Spec-built, sanitizer +
shape unit-tested, lint + tsc clean. End-to-end against Meta needs the
reporter (or a maintainer with an account) to verify. If a real
round-trip surfaces a mismatch, drop it in #647 and I'll iterate.
This commit is contained in:
@@ -2,9 +2,13 @@ import React, { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, Send, Eye, EyeOff } from 'lucide-react';
|
||||
import { Save, Send, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
|
||||
import { whatsappService } from '../../../services/whatsapp.service';
|
||||
import {
|
||||
whatsappService,
|
||||
WHATSAPP_TEMPLATE_PARAMS,
|
||||
type WhatsAppTemplateParam,
|
||||
} from '../../../services/whatsapp.service';
|
||||
|
||||
/**
|
||||
* WhatsApp Business API configuration tab (#640D).
|
||||
@@ -32,6 +36,9 @@ export const WhatsAppTab: React.FC = () => {
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [templateName, setTemplateName] = useState('gallery_ready');
|
||||
const [templateLanguage, setTemplateLanguage] = useState('');
|
||||
const [templateParams, setTemplateParams] = useState<WhatsAppTemplateParam[]>(
|
||||
[...WHATSAPP_TEMPLATE_PARAMS],
|
||||
);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [testPhone, setTestPhone] = useState('');
|
||||
@@ -45,10 +52,36 @@ export const WhatsAppTab: React.FC = () => {
|
||||
setAccessToken(data.access_token || '');
|
||||
setTemplateName(data.template_name || 'gallery_ready');
|
||||
setTemplateLanguage(data.template_language || '');
|
||||
// The server always returns a non-empty sanitized array (default 5-slot
|
||||
// shape when the column is empty), so we can take it directly.
|
||||
setTemplateParams(
|
||||
data.template_params && data.template_params.length > 0
|
||||
? data.template_params
|
||||
: [...WHATSAPP_TEMPLATE_PARAMS],
|
||||
);
|
||||
setEnabled(Boolean(data.enabled));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Toggle inclusion of a slot. When checked we append at the end (highest
|
||||
// {{N}}); when unchecked we drop it from the list. Reordering uses the
|
||||
// up/down buttons below.
|
||||
const toggleParam = (key: WhatsAppTemplateParam) => {
|
||||
setTemplateParams((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const moveParam = (idx: number, delta: -1 | 1) => {
|
||||
setTemplateParams((prev) => {
|
||||
const target = idx + delta;
|
||||
if (target < 0 || target >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => whatsappService.updateConfig({
|
||||
phone_number_id: phoneNumberId,
|
||||
@@ -56,6 +89,7 @@ export const WhatsAppTab: React.FC = () => {
|
||||
access_token: accessToken,
|
||||
template_name: templateName,
|
||||
template_language: templateLanguage,
|
||||
template_params: templateParams,
|
||||
enabled,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -195,6 +229,81 @@ export const WhatsAppTab: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Template parameter selection (#647 follow-up). Reporter's
|
||||
template uses only event_name + gallery_link, but the legacy
|
||||
shape hardcoded a 5-parameter `gallery_ready` payload that Meta
|
||||
rejected with a parameter-count mismatch. This control lets the
|
||||
admin pick which slots to send and in what positional order. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.whatsapp.templateParams', 'Template parameters')}
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'settings.whatsapp.templateParamsHint',
|
||||
'Pick which built-in values are sent as positional template parameters (slot 1, slot 2, …), and arrange them so they match the order in your Meta-registered template body. Unchecked slots are not sent at all. Default matches the built-in `gallery_ready` 5-parameter shape.',
|
||||
)}
|
||||
</p>
|
||||
<ul className="rounded-lg border border-neutral-200 dark:border-neutral-700 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{WHATSAPP_TEMPLATE_PARAMS.map((slot) => {
|
||||
const idx = templateParams.indexOf(slot);
|
||||
const included = idx >= 0;
|
||||
return (
|
||||
<li
|
||||
key={slot}
|
||||
className="flex items-center gap-3 p-3 bg-white dark:bg-neutral-900"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
onChange={() => toggleParam(slot)}
|
||||
className="rounded border-neutral-300"
|
||||
aria-label={t(`settings.whatsapp.params.${slot}`, slot) as string}
|
||||
/>
|
||||
<span className="flex-1 text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<span className="font-mono text-xs text-neutral-500 dark:text-neutral-400 mr-2">
|
||||
{included ? `{{${idx + 1}}}` : '—'}
|
||||
</span>
|
||||
{t(`settings.whatsapp.params.${slot}`, slot)}
|
||||
</span>
|
||||
{included && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveParam(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 disabled:opacity-30"
|
||||
aria-label={t('settings.whatsapp.paramMoveUp', 'Move up') as string}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveParam(idx, 1)}
|
||||
disabled={idx === templateParams.length - 1}
|
||||
className="p-1 disabled:opacity-30"
|
||||
aria-label={t('settings.whatsapp.paramMoveDown', 'Move down') as string}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{templateParams.length === 0
|
||||
? t(
|
||||
'settings.whatsapp.templateParamsEmpty',
|
||||
'No slots selected — saving will fall back to the default 5-parameter shape.',
|
||||
)
|
||||
: t('settings.whatsapp.templateParamsPreview', 'Your template will receive: {{preview}}', {
|
||||
preview: templateParams.map((slot, i) => `{{${i + 1}}} = ${slot}`).join(', '),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
Reference in New Issue
Block a user