Skip to content

feat(templates): template builder with live preview, validation, and payload preview - #515

Open
Rax498 wants to merge 40 commits into
shridarpatil:mainfrom
Rax498:feature/template-ui-improvement
Open

feat(templates): template builder with live preview, validation, and payload preview#515
Rax498 wants to merge 40 commits into
shridarpatil:mainfrom
Rax498:feature/template-ui-improvement

Conversation

@Rax498

@Rax498 Rax498 commented Jul 21, 2026

Copy link
Copy Markdown

Summary

Rebuilds the template create/edit page around a richer editor, keeping the app's
existing page structure (list at /templates, create/edit at /templates/new and /:id).

image

What this adds vs main:

  • Live WhatsApp-style preview while creating and editing (previously only a plain
    preview after saving): WhatsApp text formatting (bold, italic, strike, mono),
    media headers, per-type button icons, and sample values substituted into variables.
  • Client-side validation of Meta's rules with clear messages before submission:
    • character limits (header 60, body 1024, footer 60, button label 25)
    • variable rules: no mixing positional/named, sequential {{1}}, {{2}}…, no variables at
      body edges, sample values required
    • button rules: max 10 total, max 2 call-to-action (URL/phone), per-type caps, and
      same-type grouping — with reorder controls, since array order is what Meta receives
  • All 7 button types the backend accepts: quick reply, URL (static and dynamic with
    example value), phone, copy code, flow, voice call, OTP.
  • AUTHENTICATION templates fully supported in the editor: OTP delivery type
    (copy code / one-tap / zero-tap + supported apps), security recommendation,
    code expiration.
  • Payload inspection before sending: a JSON dialog showing both the exact API
    request body and the exact Meta payload (built by the backend — see below).
  • Media headers preview locally (object URL); the file is uploaded to Meta only on
    save, so abandoned drafts no longer leave orphaned upload handles.
  • Publish a draft directly from the templates list.

Backend changes (3 files, no behaviour change)

  • cmd/whatomate/main.go — 1 line: registers POST /api/templates/preview
  • internal/handlers/templates.go — new PreviewTemplate handler. Read-only: builds
    the payload in memory and returns it. No DB write, no Meta call.
  • pkg/whatsapp/template.go — moves the payload-wrapping block out of
    SubmitTemplate into BuildSubmissionPayload, now called by both SubmitTemplate
    and the preview. The component builders are untouched; publishing sends the same
    payload as before.

e2e spec updates

All 36 template specs pass. Four expectations were updated minimally because this PR
replaces UI they encoded (the collapsible Details card and the Preview button + modal
are superseded by the always-visible editor with a live sidebar preview); test intent
is preserved. Also fixed a self-colliding locator in the media-header spec that matched
the spec's own seeded account name.

Known limitation

Media headers are previewed live while creating a template (local object URL).
For already-saved templates the preview shows a placeholder instead of the real
image: whatomate deliberately stores only Meta's upload handle, which is not
downloadable, and keeps no copy of the media. A possible follow-up is rendering
the temporary media URL Meta returns during template sync.

Rax498 added 22 commits March 12, 2026 11:45
…ontract

TemplatesView.vue was committed with 14 unresolved merge conflict hunks
(42 markers), so the branch has not compiled since dd5ce3f. Resolve to the
list-only view from main, which keeps the quality_rating column and routes
create/edit through /templates/new and /templates/:id.

Fix the sample_values shape. The editor wrote {component, param_name, value}
with no index, but the backend's positional path (extractExamplesForComponent
in pkg/whatsapp/template.go) reads svMap["index"] and defaults it to 1 when
absent, then sorts on it. Every sample collapsed to index 1, so Meta received
examples in whatever order the user happened to fill them in. Now write
component + index + param_name so both the positional and the named
(extractNamedExamplesForComponent) backend paths resolve correctly.

Scope TemplatePreview variable substitution per component so header samples no
longer fill body variables, and resolve positional {{1}} via index instead of
param_name only. Replace the split/join substitution with a function replacer,
which returns its string verbatim and so cannot expand $& / $1 tokens.

Revert PreviewMessage.vue and flow-preview.ts to main: those 261 lines were
pure Prettier churn (double quotes + semicolons against the repo's single
quotes, no semicolons) with no semantic change.

Drop the committed vite.config.ts.timestamp-*.mjs build artifact and ignore it.
Remove debug console.log/console.error from the media upload handler and report
upload failures through getErrorMessage, matching the rest of the codebase.
…/new and /:id

TemplateEditor and TemplatePreview were never wired to a route. The router sent
/templates/new and /templates/:id to TemplateDetailView, so the app rendered the
plain form while the richer editor sat unreachable behind a Dialog in
TemplatesView. That dialog is gone, so this hooks the editor up to the route it
was always meant to serve.

TemplateDetailView keeps everything it already owned -- route params, load, save,
publish, delete, permissions, audit log, unsaved-changes guard and validation --
and now renders TemplateEditor for the form. That drops ~460 lines of duplicated
markup and the declarations behind it. The preview modal goes too: the editor has
a live sticky preview that also works while creating, which the modal never did
(it was gated on !isNew).

Port AUTHENTICATION support into the editor. It offered AUTHENTICATION in the
category dropdown but had none of the fields, so choosing it produced a template
Meta would reject: no code-delivery method, no OTP button, no expiry. It now
carries the delivery selector (copy-code / one-tap / zero-tap), the security
recommendation and expiry options, the zero-tap terms gate and supported-apps
editor, and hides header, body, footer, sample and button editors, which Meta
fixes for this category.

Zero-tap acceptance moves onto the form object. save() gates on it, but the
checkbox now lives in the editor, so a local ref in the parent would have stayed
false and made every zero-tap save fail. It is not part of the API payload --
that is built field by field.

Align allVariables' index rule with the editor's sampleIndexFor(). The parent
prunes sample_values whose index no longer matches a variable; computing that
index differently on each side would have dropped valid samples for an
out-of-order body such as {{2}} ... {{1}}.

Add a status/quality card to the sidebar to keep the status, quality rating and
Meta ID visible now that the preview modal that showed them is gone.

Typecheck, production build and eslint are clean. The e2e suite needs a running
server and was not exercised.
Auditing the routed editor against main's form surfaced four gaps that would
have shipped as regressions once the editor took over the route.

Button types. The backend accepts seven (pkg/whatsapp/template.go), and main's
UI offered all seven. The editor offered three, so COPY_CODE, FLOW and
VOICE_CALL buttons became unreachable and whatsappFlows was loaded but rendered
nowhere. Add all three, with the flow picker, navigate/data_exchange action and
screen selector wired to the parent's published-flows list. A FLOW button
without a flow_id is dropped server-side, so the picker is required.

display_name had no input at all, yet save() sends it and the list renders
display_name || name. Templates created here would have had none.

Identity fields locked on isEdit, so an unpublished draft could not be renamed.
Meta only freezes name, language, category and account once it has the template,
which is what main gated on. Lock on is-published (meta_template_id) instead.

Restore the edit-limits notice for approved templates.

Also swap the three alert() calls in the button limits for toast.error, matching
the rest of the codebase, and give the preview icons for the new button types.

Verified: every field in the save payload now has an input, and all seven button
types are reachable. Payload shape and all nine save guards are unchanged from
main. Typecheck, build and eslint clean; e2e still not run.
Picking a header image uploaded it to Meta immediately, just to render a
preview. upload-media calls ResumableUpload, which opens an upload session and
pushes the bytes to Meta straight away. Nothing is written to our database or
storage, so this did not clog anything on our side, but every file pick — and
every re-pick — burned two Meta API calls and left a handle that nothing ever
cleans up if the user walked away.

Preview the picked file from a local object URL instead, and upload it once in
save(), which is also where the reference implementation does it. The editor now
makes no API calls at all; it is purely presentational and the parent owns
persistence. Meta handles are opaque, not URLs, so the preview renders the
picked file and falls back to a placeholder for an already-saved header. That
also removes the headerContent.startsWith('4') hack that was standing in for
this check.

Validate the template name the way the backend does. normalizeTemplateName()
lowercases, folds spaces and dashes to underscores and strips the rest, so
typing "Order Update!" silently saved "order_update". The field now normalizes
as you type and says what it accepts.

Add the save guards that were missing against Meta's rules: variables must run
sequentially from {{1}}, every variable needs a sample value, and buttons need a
label, a URL, a phone number or a Flow depending on type — with a dynamic URL
required to end in {{1}}. Empty sample inputs are outlined in red rather than
only failing on save. Accept and size limits are set per header type.

Also drop the dead resetState/defineExpose left over from the old dialog, and
trim the comments down to the ones stating a constraint the code cannot show.

Typecheck, build and eslint clean. e2e still not run.
Running the template e2e suite against a live backend showed the editor had
drifted from the markup the rest of the app uses, breaking 13 existing tests.

Use the shared Select component for account, category and header type instead of
raw <select>. Every other form in the app uses Select, and the tests locate these
fields by role="combobox".

Restore the ids and labels the app already uses: #header-content, #display-name,
#footer-content, and the "Sample Values for Variables" heading. Variable chips
read body:{{name}} again rather than {{name}}, and sample inputs are placeheld
"e.g. name".

Make the footer a Textarea, not an Input, matching the body and the 60-character
Meta limit.

Show the "at most one variable in a TEXT header" error inline under the field.
It was only reachable as a toast on save, so there was no feedback while typing.

Template e2e now: 28 passed, 6 failed. All 6 remaining failures are
"Failed to create WhatsApp account", thrown by the API test helper before any
page loads, because encryption_key is unset in the local config. They are not
frontend failures.
Neither the API nor main's form capped any field, so an over-length template was
only rejected once it reached Meta, well after the user had left the page.

Add Meta's limits to the editor, with a live counter next to each field and a
maxlength that stops the field going over: header 60, body 1024, footer 60,
button label 25, copy-code example 15, URL 2000. save() rechecks the same limits
so nothing over-length can reach the API.

Validate phone-number buttons against international format, inline and on save.
Show empty button labels with a red border rather than only failing on save.

Template e2e unchanged at 28 passed / 6 failed; the 6 are the pre-existing
account-creation failures in the test helper, not frontend.
Resolving TemplatesView to main's version dropped the publish action this branch
had on the list. Restore it as a row action, using the same
POST /templates/:id/publish call the detail page uses, shown only for drafts
(no meta_template_id, or status DRAFT).

Keeps everything main's list gained in the meantime: the quality-rating column,
search pagination and RouterLink navigation.
The form was squeezed into roughly half the page. DetailPageLayout already
splits its max-w-6xl into a 2/3 content column and a 1/3 sidebar, and the editor
was splitting that content column again 7/5 to sit its own preview panel beside
the form. Two nested splits inside a capped width left the form about 430px wide
with fields stacked on top of each other.

Move the live preview into the page's sidebar slot, which is what that column is
for, and let the form use the full content column with no internal split. The
preview is sticky, so it stays in view while the form scrolls, and it now shows
on the create page too.

Lay the identity fields out 3-up instead of 4-up so Category no longer drops
onto a line by itself, and fill the empty half of the header card with the hint
that a header is optional.

Fix two labels that were being eaten by Vue interpolation: the body hint printed
"Variables must be sequential 1, 2..." and the submission notice "Variables like
1 will be replaced". Both now render the braces literally via v-pre.

The object URL for the picked file moves to the parent along with the preview, so
the editor no longer creates or revokes it.

Template e2e unchanged: 28 passed, 6 failed (the pre-existing account-creation
failures in the test helper).
Adds a JSON button to the header that opens the exact body the form will POST or
PUT, with the method and URL above it and a copy button. Useful for verifying a
template before submitting it, for anyone driving the API directly, and for
debugging a rejection from Meta.

save() no longer builds the payload inline. buildPayload() is the single source,
and the dialog renders that same function, so what you inspect is what gets sent
and the two cannot drift apart.

Looking at the payload immediately showed the editor was sending a client-side
`id` on every button — a UUID it generates only as a v-for key — which was being
written into the buttons JSONB. buildPayload() now strips it.

For a media header the dialog notes that header_content is empty until save,
because the file is uploaded to Meta at that point and the handle it returns is
what gets sent.

Template e2e unchanged: 28 passed, 6 failed (pre-existing account-creation
failures in the test helper).
Extract the payload-wrapping block out of SubmitTemplate into
BuildSubmissionPayload and add POST /api/templates/preview, which runs the
same builder and returns the components JSON without saving anything or
calling Meta. Publishing behaviour is unchanged — one builder, two callers —
so the JSON dialog can show the real Meta payload instead of a hand-written
frontend copy that would drift.
Move the button limits into src/lib/templateButtons.ts as the single source:
10 total, 2 call-to-action (URL/phone combined), one each of phone, copy
code, flow and call, and same-type buttons must stay contiguous. The editor
shows an inline warning when the combination is invalid, adds up/down
reorder controls (order is what Meta receives), inserts new buttons next to
their group so the set stays valid as it grows, and starts each button with
a sensible default label. Dynamic URL buttons edit the example as the bare
variable value with the URL base shown as a prefix.

Also updates the stale templates.maxButtons locale string (3 → 10 in all
five locales) — left over from the old 3-button editor, it shadowed the
correct message.
The JSON dialog gains a Meta payload tab that calls the new preview
endpoint, so what it shows is built by the same code that publishes. The
API request tab keeps showing the exact POST/PUT body.

Along the way in the detail view:
- send a dynamic URL button's example as the bare variable value ("Rose",
  not the full URL) per Meta's creation examples; templates synced from
  Meta return full URLs, so loading strips the base for the editor
- run the shared button-combination check as a save guard
- keep the live preview sticky only on the create page — on the edit page
  the status/metadata cards scrolled up over the pinned card
- drop the leftover isDetailsOpen ref from the old collapsible card
The detail page no longer has a collapsible Details card or a Preview
button + modal — the editor is always visible with a live sidebar preview.
Update the four expectations that encoded the old UI (assert Live Preview,
target #display-name directly, check the sidebar preview instead of the
modal) and pin the header-type combobox to its exact "None" value so it
stops colliding with account names that contain "header".
Add an eye button to each row that opens a read-only WhatsApp-style preview
dialog, reusing the existing TemplatePreview component — previously the only
way to see a template's content was to open it in the editor.
Vue casts an absent boolean prop to false, so DropdownMenuRoot always
received open=false and reka-ui treated the menu as controlled: the
trigger's toggle only emitted an update nobody listened to, and the
menu could never open. Default the prop to undefined so the menu
manages its own state unless a caller actually binds open.
Publish and preview stay as one-click row actions; edit and delete
move behind a three-dots menu so the actions column stays compact.
Adds the common.moreActions label to all locales and updates the
list-delete spec and page object to go through the menu.
@Rax498
Rax498 force-pushed the feature/template-ui-improvement branch from 666d265 to 19510ad Compare July 22, 2026 05:58

@shridarpatil shridarpatil left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through the whole diff. The BuildSubmissionPayload extraction is a faithful refactor, and TemplatePreview escapes then DOMPurify-sanitizes with a function replacer so sample values can't inject $&/$1 — no XSS there. The DropdownMenu open: undefined fix also unbreaks ChatView's contact-options menu, the only other consumer, which was dead on main for the same reason.

Two data-correctness bugs in the new sample-value indexing, both reproduced by replaying the actual functions:

  1. A named variable used twice makes the template unsavable — the editor dedupes variable names, missingSamples doesn't, so it demands a sample for an input that isn't rendered. This is the case the comment on TemplateDetailView.vue:192 claims passes.
  2. Deleting a non-final named variable makes findSample's index fallback hand {{city}} the value that belonged to {{name}}, and ships param_name: "name" to Meta.

Plus an i18n regression (~33 labels that were $t() on main are now hardcoded, 18 new keys never added to en.json, and the Crowdin-managed locale files hand-edited), and button v-for keys going undefined after every save. Details inline.

Verification: eslint clean on all changed frontend files; vue-tsc --noEmit shows only one pre-existing error in AccountDetailView.vue (untouched here). Go build/vet couldn't run in my sandbox (module proxy unreachable), so templates.go / template.go were reviewed by reading only.

const existing = form.value.sample_values.findIndex(
(s: any) => s.component === component && s.index === index
// Meta rejects a template whose variables have no example values.
const missingSamples = computed(() =>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A named variable used twice can never be saved.

extractParamNames() in TemplateEditor.vue dedupes (it keeps a seen set), so the editor renders one input per unique name. allVariables here counts every occurrence and indexes by occurrence position. The comment on line 153 says the two must agree — they diverge exactly on duplicates.

Body Hi {{name}}, your order for {{item}} ships today. Thanks {{name}} for shopping.:

editor inputs : [ 'name', 'item' ]    -> writes samples index 1, 2
allVariables  : name@1, item@2, name@3
missingSamples: [ '{{name}}' ]        -> save() bails at line 506

The toast reads "Add sample values for: {{name}}" and there is no input that can satisfy it — the template becomes unsavable.

Same for a TEXT header Hi {{name}} - welcome {{name}}, which is precisely the case the comment on line 192 claims passes: hasTooManyHeaderVariables dedupes with a Set, missingSamples does not.

Fix: dedupe allVariables by (component, name) so it matches sampleIndexFor(), or drop the occurrence-position indexing for named params.

return samples.find(
(s: any) =>
s.component === component &&
(s.param_name === paramName || s.index === index),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting a non-final named variable silently submits another variable's sample.

The s.index === index fallback fires for named params too, so a sample can be matched by slot even when its param_name is something else. Remove {{name}} from Hi {{name}}, we ship to {{city}} tomorrow.:

samples before      : name@1="Ada", city@2="Berlin"
after prune watcher : name@1="Ada"            (TemplateDetailView.vue:261)
findSample('body','city') -> {"param_name":"name","value":"Ada"}
missingSamples      : []                      -> save proceeds

So the {{city}} input displays Ada with no warning, and the payload keeps param_name: "name". extractNamedExamplesForComponent (pkg/whatsapp/template.go:545) keys named examples off param_name, so Meta gets body_text_named_params: [{param_name:"name", ...}] for a body containing {{city}} and rejects the template with an opaque error.

TemplatePreview.vue:86-96 already gets this right — index only when the token is numeric, param_name otherwise. Worth reusing that rule here.

<div class="space-y-1.5">
<div class="flex items-center h-7">
<Label class="text-xs font-bold text-muted-foreground"
>Header Type</Label

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n regression. These labels were $t() on main (e.g. main:767 had $t('templates.headerType', 'Header Type')); this file hardcodes ~33 user-visible strings — Header Type, Header Text, Message Body, Footer Text, Action Buttons, Sample Values for Variables, Code Delivery Method, Supported Apps, the OTP explanations, "Before you submit", the Flow/Screen selects and their placeholders. All five locales lose them.


onMounted(() => {
if (state.value?.buttons) {
state.value.buttons = state.value.buttons.map((b: any) => ({

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Button v-for keys are lost after every save.

Ids are assigned in onMounted only. save() -> loadTemplate() -> syncForm() rebuilds form.buttons from the server response, which carries no ids (cleanButton strips them on the way out). The props watcher on line 117 then sees a JSON.stringify difference and replaces state wholesale, so :key="btn.id" on line 1326 is undefined for every button — duplicate-key warnings, and the keyed diff degenerates to positional.

Buttons the parent creates later have the same problem: the category watcher at TemplateDetailView.vue:358 pushes an OTP button with no id, and onMounted has already run.

Normalizing ids inside the props watcher (or keying on index) fixes both.

// The preview renders live in the sidebar; the body's variable substitution
// happens inline, so the template text is visible without opening anything.
await expect(page.getByText('Live Preview')).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/welcome/).first()).toBeVisible()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is still named "should show preview with sample values replacing variables", but the template it seeds has no sample_values at all, so it can't assert substitution — it now only checks that the sidebar heading and the body text render.

The old version was equally vacuous (it just opened the dialog and closed it), so no coverage is lost. But the headline feature of the PR — samples substituted into the live preview — ends up with no assertion anywhere, and neither do validateButtonCombination, the Meta-payload dialog, or any of the new client-side validations. Seeding sample_values and asserting the green pill contains the value is cheap, and would have caught the two indexing bugs I flagged in TemplateEditor.vue / TemplateDetailView.vue.

const URL_VAR = "{{1}}";

function isDynamicUrl(btn: any) {
return String(btn.url || "").includes(URL_VAR);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only {{1}} is recognised as dynamic. A URL button synced from Meta that uses a named param (https://x/{{order}}) shows as Static, so no example is collected and firstButtonError doesn't ask for one — then the backend's URL branch sees strings.Contains(btnURL, "{{") and sends the URL with no example, which Meta rejects.

}

function insertAtCursor(textToInsert: string, cursorOffset: number = 0) {
const startPos = savedSelectionStart.value;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

savedSelectionStart starts at 0 and is only updated on blur/keyup/mouseup, so if the textarea was never focused, "Add Variable" and the format buttons insert at the very beginning of the body rather than the end.

emit("update:mediaFile", file);
}

function clearMediaFile() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mediaFileName is never cleared when the parent nulls pendingMediaFile after a successful upload in save(), so the filename chip persists instead of switching to "Sample already uploaded. Choose a file to replace it."


// A DRAFT template lives only in our database until it is submitted to Meta.
function isDraft(template: Template) {
return !template.meta_template_id || template.status?.toUpperCase() === 'DRAFT'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDraft() offers Publish for !meta_template_id || DRAFT, but the detail page's canPublish uses DRAFT || REJECTED. A REJECTED template can be republished from the detail page but not from the list — worth making the two agree.

Also note this action (and Edit/Delete in the new overflow menu) isn't gated on templates:write/delete the way the detail page gates its buttons. Delete was already ungated here on main, so no regression, just carrying it forward to a new action.

return nil
}

payload, err := a.WhatsApp.BuildSubmissionPayload(&whatsapp.TemplateSubmission{

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the fidelity claim and it holds: submitTemplateToMeta also leaves ParameterFormat empty, so both paths derive parameter_format purely from hasNamedParams(body), and CreateTemplate normalizes Name/Category/HeaderType exactly as this does. The preview really is the same payload.

The extraction into BuildSubmissionPayload is faithful too — template.MetaTemplateID != "" is the same condition the old inline isUpdate used. No permission check here matches the rest of templates.go, which has none either.

Rax498 added 6 commits August 14, 2026 11:44
If a body used the same variable twice, like "Hi {{name}} ... Thanks
{{name}}", the template could never be saved. The editor shows one box
per variable name, so it showed one box for {{name}}. But this file
counted {{name}} twice, so it kept asking for a second sample value.
The error said "Add sample values for: {{name}}" while the only
{{name}} box on screen was already filled in. A text header with the
same name twice failed the same way.

Now each variable name is counted once per component, and its position
is taken from that same list, so both files agree on which sample value
belongs to which variable.

The lists that find repeated {{1}} style variables are left as they
were, because that check needs to see the duplicates.
Sample values for named variables were matched by position, not by
name. Take "Hi {{name}}, we ship to {{city}} tomorrow." with Ada and
Berlin filled in, then delete {{name}}. The {{city}} box showed Ada,
and the template was sent to Meta saying that value belonged to
{{name}} - a variable the body no longer had. Meta rejected it with an
unclear error.

Named variables are now matched by name only. Numbered variables like
{{1}} still match by position, and old templates saved without a name
on the sample still work, so nothing that worked before breaks.

The cleanup step that runs when the body changes had the same problem.
It was throwing away Berlin and keeping Ada. It now keeps the value
whose name is still in the body, and renumbers it to the variable's new
position so the save check sees it.
Each button gets a random id when the editor starts, and the button
list uses that id as its key. Saving reloads the template from the
server, which never sends ids back, so from then on every button had
no key. Vue warned about duplicate keys and fell back to matching
buttons by position, which is the thing the ids were added to avoid.

The ids are now restored whenever a new value arrives from the parent,
not only at startup. This also covers the OTP button the parent adds
when the category is switched to authentication.
Only {{1}} counted as a dynamic url. A template synced from Meta can
use a named variable instead, like https://shop.com/orders/{{order}}.
Those buttons showed as Static, so the editor never asked for an
example value, and the backend then sent the url to Meta without one
and the template was rejected.

Detecting and stripping the variable now accepts any {{...}}. New
dynamic urls are still created with {{1}}.
The remembered cursor position started at 0 and was only updated once
the body textarea had been clicked or typed in. So on a template loaded
for editing, pressing Add Variable or a formatting button before
touching the body inserted the text at the very front of the message
instead of the end.

The position now starts unset and falls back to the end of the body.
The chosen filename was held in its own ref, so it stayed on screen
after save() uploaded the file to Meta and set the pending file back to
null. The hint that a sample is already uploaded and can be replaced
never appeared.

The parent already passes the pending file down, so the name is now
read from it and the two cannot disagree.
Rax498 added 12 commits August 14, 2026 13:20
Every other place that reads the OTP button uses optional chaining;
this one input bound straight to otpButton().autofill_text. It cannot
crash today, because the delivery type only reads ONE_TAP when an OTP
button exists, but nothing in the code says so.
The list showed the publish action for drafts only, while the detail
page offers it for drafts and rejected templates. So a rejected
template could be fixed and sent again from its own page, but not from
the list. Both now agree.
The old form ran its labels through $t(). When the form moved into
TemplateEditor the text was written straight into the markup, so about
60 labels, hints and placeholders stopped being translatable and all
five languages lost them. Two keys the editor already called for,
displayNamePlaceholder and maxVariables, were never in en.json either,
so those fell back to the raw key.

Every label, hint, placeholder and button title now reads from a key.
Ten of them reuse keys that were already there; the rest are new. The
English wording is unchanged, so the screen looks the same.

New keys go in en.json only. crowdin.yml lists it as the source file
and pulls the other four languages from Crowdin, so hand-writing
translations there would be overwritten on the next sync.

Sample values shown as examples, like the coupon code and the signature
hash, are left alone. They are illustrations, not interface text.
The test was called "should show preview with sample values replacing
variables" but the template it created had no sample values, so there
was nothing to substitute. It only checked that the heading and the
body text appeared, which would still pass if substitution were broken.

It now seeds a sample value and asserts the preview reads "Hello Ada,
welcome" with no {{1}} left on screen. The API helper accepts
sample_values so the template can be created with one.
The editor's button-limit toasts and the whole JSON/payload dialog were
still English literals, so all five locales lost them.

Two of the messages interpolate values, and t(key, fallback) drops the
fallback once the key exists — a single key would have silently collapsed
"Only one button of this type" into the plural wording, and eaten the
expected/found numbers. Those use named parameters instead.

Only en.json is touched; crowdin.yml makes it the source and generates
the other four.
validateButtonCombination had no assertions anywhere. These 20 cases pin
the total cap, the per-type caps, the grouping rule, and the combined
call-to-action cap — URL and phone share one budget of two, so 2 URL plus
1 phone is rejected even though each type is within its own limit.

Verified the suite actually guards the rules: flipping MAX_CTA to 3 fails
exactly one test.
The assertion searched the whole page for {{1}}, which passed only while
the preview lived in a modal that covered the editor. With the editor and
preview side by side the body textarea legitimately still shows the raw
token, so the test failed on a working feature.
The editor treats any {{...}} in a url button as dynamic, but the save
guard still looked for the literal {{1}}. So a url carrying a named
variable showed its example box, and saving went through without one.
The backend asks strings.Contains(url, "{{"), so it then sent the url to
Meta with no example and the template was rejected.

The suffix chip had the same split. It was a fixed {{ 1 }}, so a url
holding {{order}} read as {{1}} on screen while the data said otherwise,
and editing the prefix rewrote {{order}} to {{1}} without saying so.

The test now lives in templateButtons.ts and both files use it. Urls the
editor creates are unchanged: it still only ever writes {{1}}, and every
check is the same one it was for a {{1}} url. Named variables only reach
us by syncing a template written elsewhere.
The labels were translated earlier, but ten strings this branch introduced
were still English literals: the media sample hint, the sample values hint,
the voice call hint and the six add-button chips. All five locales showed
them in English.

The zero-tap delivery hint is translated alongside the one-tap hint next to
it, since the two are alternatives in the same paragraph and splitting them
would leave the block half translated.

Only en.json is touched; crowdin.yml makes it the source for the rest.
crowdin.yml makes en.json the source and generates ar, es, hi and ta from
it. The branch had edited those four by hand to add moreActions and to
correct the maxButtons count, which bypasses the translators and is
overwritten on the next sync. The English strings stay in en.json and
crowdin picks them up from there.
The sample media field used the browser's default file button, which
ignores the surrounding styles and looked out of place next to the rest
of the form. Tailwind's file: variant styles that button directly, so it
now uses the theme's muted and accent tokens and follows light and dark
like every other control.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants