Skip to content

Commit b30bb81

Browse files
committed
feat(whatsapp): replace technical template inputs with product and date pickers
1 parent a964e26 commit b30bb81

43 files changed

Lines changed: 2278 additions & 206 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, test } from "vitest"
2+
import { createBroadcastRequest } from "@/features/broadcasts/schemas/action"
3+
4+
const baseRequest = {
5+
channel: "whatsapp",
6+
templateId: "11612473309626368",
7+
integrationWhatsappId: "11612473309626369",
8+
subaction: "whatsappTemplateMessage",
9+
schedulesType: "now",
10+
schedulesAt: null,
11+
contactFilter: { operator: "and", conditions: [] },
12+
}
13+
14+
const mpmTemplateData = (
15+
sections: Array<{
16+
title?: string
17+
product_items?: Array<{ product_retailer_id: string }>
18+
}>,
19+
) => ({
20+
button: [{ sub_type: "mpm", index: 0, sections }],
21+
})
22+
23+
describe("createBroadcastRequest — MPM validation (broadcast surface)", () => {
24+
test("rejects a WhatsApp MPM broadcast with no configured products", () => {
25+
const result = createBroadcastRequest.safeParse({
26+
...baseRequest,
27+
templateData: mpmTemplateData([]),
28+
})
29+
30+
expect(result.success).toBe(false)
31+
})
32+
33+
test("accepts a WhatsApp MPM broadcast with configured products", () => {
34+
const result = createBroadcastRequest.safeParse({
35+
...baseRequest,
36+
templateData: mpmTemplateData([
37+
{
38+
title: "Best sellers",
39+
product_items: [{ product_retailer_id: "sku-1" }],
40+
},
41+
]),
42+
})
43+
44+
expect(result.success).toBe(true)
45+
})
46+
47+
test("rejects a WhatsApp MPM broadcast exceeding the section limit", () => {
48+
const sections = Array.from({ length: 11 }, (_ignored, index) => ({
49+
title: `Section ${index + 1}`,
50+
product_items: [{ product_retailer_id: `sku-${index}` }],
51+
}))
52+
53+
const result = createBroadcastRequest.safeParse({
54+
...baseRequest,
55+
templateData: mpmTemplateData(sections),
56+
})
57+
58+
expect(result.success).toBe(false)
59+
})
60+
61+
test("non-MPM WhatsApp template data is unaffected (regression)", () => {
62+
const result = createBroadcastRequest.safeParse({
63+
...baseRequest,
64+
templateData: {
65+
body: [{ type: "text", text: "Hello" }],
66+
button: [{ sub_type: "quick_reply", index: 1, payload: "" }],
67+
},
68+
})
69+
70+
expect(result.success).toBe(true)
71+
})
72+
73+
test("rejects a WhatsApp MPM broadcast containing an unfilled product row", () => {
74+
const result = createBroadcastRequest.safeParse({
75+
...baseRequest,
76+
templateData: mpmTemplateData([
77+
{
78+
title: "Best sellers",
79+
product_items: [
80+
{ product_retailer_id: "sku-1" },
81+
{ product_retailer_id: "" },
82+
],
83+
},
84+
]),
85+
})
86+
87+
expect(result.success).toBe(false)
88+
})
89+
90+
test("rejects a WhatsApp LTO broadcast whose expiration is still the seeded 0", () => {
91+
const result = createBroadcastRequest.safeParse({
92+
...baseRequest,
93+
templateData: { limited_time_offer: { expiration_time_ms: 0 } },
94+
})
95+
96+
expect(result.success).toBe(false)
97+
})
98+
99+
test("accepts a WhatsApp LTO broadcast with a configured expiration", () => {
100+
const result = createBroadcastRequest.safeParse({
101+
...baseRequest,
102+
templateData: {
103+
limited_time_offer: { expiration_time_ms: 1_924_000_000_000 },
104+
},
105+
})
106+
107+
expect(result.success).toBe(true)
108+
})
109+
110+
test("messenger broadcasts are not run through the WhatsApp MPM rule (regression)", () => {
111+
const result = createBroadcastRequest.safeParse({
112+
...baseRequest,
113+
channel: "messenger",
114+
integrationWhatsappId: undefined,
115+
integrationMessengerId: "11612473309626370",
116+
subaction: "messengerTemplateMessage",
117+
templateData: { body: [{ type: "text", text: "Hi" }] },
118+
})
119+
120+
expect(result.success).toBe(true)
121+
})
122+
})

apps/builder/messages/ar.json

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2952,7 +2952,12 @@
29522952
"redoHistoryCleared": "تم مسح سجل الإعادة",
29532953
"historyDepth": "({count})",
29542954
"none": "لا شيء",
2955-
"tiktokCardTitleTooLong": "سيقوم TikTok بتقصير هذه الرسالة: عند إرفاق أزرار، يتم إرسال أول 40 حرفًا فقط. قم بتقصير الرسالة أو إزالة الأزرار."
2955+
"tiktokCardTitleTooLong": "سيقوم TikTok بتقصير هذه الرسالة: عند إرفاق أزرار، يتم إرسال أول 40 حرفًا فقط. قم بتقصير الرسالة أو إزالة الأزرار.",
2956+
"waTemplateMpmNoProducts": "أضف منتجًا واحدًا على الأقل إلى زر المنتجات المتعددة هذا قبل النشر.",
2957+
"waTemplateMpmTooManySections": "يمكن أن يحتوي زر المنتجات المتعددة على 10 أقسام كحد أقصى. أزل بعض الأقسام قبل النشر.",
2958+
"waTemplateMpmTooManyProducts": "يمكن أن يحتوي زر المنتجات المتعددة على 30 منتجًا كحد أقصى إجمالاً. أزل بعض المنتجات قبل النشر.",
2959+
"waTemplateLtoExpirationRequired": "اختر تاريخ انتهاء العرض المحدود المدة قبل الإرسال.",
2960+
"waTemplateMpmIncompleteProducts": "يحتاج كل قسم إلى منتج واحد محدد على الأقل — احذف الصفوف الفارغة أو اختر منتجًا لها."
29562961
},
29572962
"instagram": {
29582963
"description": "يتيح لك هذا التكامل إنشاء بوتات Instagram.",
@@ -3359,18 +3364,35 @@
33593364
"flowToken": "رمز المسار",
33603365
"flowTokenPlaceholder": "أدخل رمز المسار",
33613366
"flowNotSynced": "زامن مسارات واتساب لربط الحقول المُرسلة.",
3362-
"catalogProductId": "معرّف المنتج",
3363-
"catalogProductIdPlaceholder": "أدخل معرّف المنتج لدى بائع التجزئة",
3367+
"catalogProductId": "منتج من الكتالوج",
3368+
"catalogProductIdPlaceholder": "البحث عن المنتجات…",
33643369
"carouselCard": "البطاقة {index}",
33653370
"limitedTimeOffer": "وقت انتهاء العرض",
3366-
"limitedTimeOfferPlaceholder": "أدخل وقت الانتهاء بالمللي ثانية",
3367-
"limitedTimeOfferHelp": "طابع Unix الزمني بالمللي ثانية عند انتهاء العرض",
3371+
"limitedTimeOfferPlaceholder": "اختر تاريخ ووقت الانتهاء",
3372+
"limitedTimeOfferHelp": "يتم إرسال وقت الانتهاء تمامًا كما تم اختياره.",
33683373
"location": "الموقع",
33693374
"latitude": "خط العرض",
33703375
"longitude": "خط الطول",
33713376
"locationName": "اسم الموقع (اختياري)",
33723377
"locationAddress": "العنوان (اختياري)",
3373-
"enterFormatUrl": "أدخل عنوان URL بتنسيق {format}"
3378+
"enterFormatUrl": "أدخل عنوان URL بتنسيق {format}",
3379+
"catalogUseDefault": "استخدام الصورة المصغّرة الافتراضية للكتالوج",
3380+
"catalogNotConnectedGuidance": "قم بربط كتالوج Meta لاختيار منتج.",
3381+
"catalogSetupLink": "الانتقال إلى إعدادات الكتالوج",
3382+
"catalogSelectedProductFallback": "المنتج: {retailerId}",
3383+
"limitedTimeOfferBadge": "تنتهي الصلاحية في {date}",
3384+
"limitedTimeOfferExpiredBadge": "انتهت الصلاحية في {date}",
3385+
"mpmSections": "المنتجات",
3386+
"mpmSectionTitlePlaceholder": "عنوان القسم (اختياري)",
3387+
"mpmAddSection": "إضافة قسم",
3388+
"mpmRemoveSection": "إزالة القسم",
3389+
"mpmAddProduct": "إضافة منتج",
3390+
"mpmRemoveProduct": "إزالة المنتج",
3391+
"mpmNoProductsHelp": "أضف منتجًا واحدًا على الأقل قبل النشر.",
3392+
"mpmSectionLimitReached": "تم الوصول إلى الحد الأقصى 10 أقسام.",
3393+
"mpmProductLimitReached": "تم الوصول إلى الحد الأقصى 30 منتجًا.",
3394+
"mpmProductsSummary": "{productCount} منتج في {sectionCount} أقسام",
3395+
"unsupportedButtonParam": "هذا النوع من الأزرار غير مدعوم بعد."
33743396
}
33753397
},
33763398
"sampleBodyCardContent": {

apps/builder/messages/da.json

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2952,7 +2952,12 @@
29522952
"redoHistoryCleared": "Gentag historik cleared",
29532953
"historyDepth": "({count})",
29542954
"none": "Ingen",
2955-
"tiktokCardTitleTooLong": "TikTok vil forkorte denne besked: når der er tilføjet knapper, sendes kun de første 40 tegn. Forkort beskeden, eller fjern knapperne."
2955+
"tiktokCardTitleTooLong": "TikTok vil forkorte denne besked: når der er tilføjet knapper, sendes kun de første 40 tegn. Forkort beskeden, eller fjern knapperne.",
2956+
"waTemplateMpmNoProducts": "Tilføj mindst ét produkt til denne multiprodukt-knap før udgivelse.",
2957+
"waTemplateMpmTooManySections": "En multiprodukt-knap kan højst have 10 sektioner. Fjern nogle sektioner før udgivelse.",
2958+
"waTemplateMpmTooManyProducts": "En multiprodukt-knap kan højst have 30 produkter i alt. Fjern nogle produkter før udgivelse.",
2959+
"waTemplateLtoExpirationRequired": "Vælg en udløbsdato for tilbuddet, før du sender.",
2960+
"waTemplateMpmIncompleteProducts": "Hver sektion skal have mindst ét valgt produkt — fjern tomme rækker, eller vælg et produkt til dem."
29562961
},
29572962
"instagram": {
29582963
"description": "Denne integration lets du opret Instagram bots.",
@@ -3359,18 +3364,35 @@
33593364
"flowToken": "Flow Token",
33603365
"flowTokenPlaceholder": "Indtast flow token",
33613366
"flowNotSynced": "Synkroniser WhatsApp-flows for at knytte indsendte felter.",
3362-
"catalogProductId": "Product ID",
3363-
"catalogProductIdPlaceholder": "Indtast product retailer ID",
3367+
"catalogProductId": "Katalogprodukt",
3368+
"catalogProductIdPlaceholder": "Søg efter produkter…",
33643369
"carouselCard": "Card {index}",
33653370
"limitedTimeOffer": "Offer Expiration Tid",
3366-
"limitedTimeOfferPlaceholder": "Indtast expiration tid i milliseconds",
3367-
"limitedTimeOfferHelp": "Unix timestamp i milliseconds når den offer expires",
3371+
"limitedTimeOfferPlaceholder": "Vælg udløbsdato og -tid",
3372+
"limitedTimeOfferHelp": "Udløbstidspunktet sendes præcis som valgt.",
33683373
"location": "Placering",
33693374
"latitude": "Latitude",
33703375
"longitude": "Longitude",
33713376
"locationName": "Placering navn (valgfri)",
33723377
"locationAddress": "Address (valgfri)",
3373-
"enterFormatUrl": "Indtast {format} URL"
3378+
"enterFormatUrl": "Indtast {format} URL",
3379+
"catalogUseDefault": "Brug kataloget standardminiature",
3380+
"catalogNotConnectedGuidance": "Tilslut et Meta-katalog for at vælge et produkt.",
3381+
"catalogSetupLink": "Gå til katalogindstillinger",
3382+
"catalogSelectedProductFallback": "Produkt: {retailerId}",
3383+
"limitedTimeOfferBadge": "Udløber {date}",
3384+
"limitedTimeOfferExpiredBadge": "Udløbet {date}",
3385+
"mpmSections": "Produkter",
3386+
"mpmSectionTitlePlaceholder": "Sektionstitel (valgfrit)",
3387+
"mpmAddSection": "Tilføj sektion",
3388+
"mpmRemoveSection": "Fjern sektion",
3389+
"mpmAddProduct": "Tilføj produkt",
3390+
"mpmRemoveProduct": "Fjern produkt",
3391+
"mpmNoProductsHelp": "Tilføj mindst ét produkt før udgivelse.",
3392+
"mpmSectionLimitReached": "Maks. 10 sektioner nået.",
3393+
"mpmProductLimitReached": "Maks. 30 produkter nået.",
3394+
"mpmProductsSummary": "{productCount} produkter i {sectionCount} sektioner",
3395+
"unsupportedButtonParam": "Denne knaptype understøttes endnu ikke."
33743396
}
33753397
},
33763398
"sampleBodyCardContent": {

apps/builder/messages/de.json

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2952,7 +2952,12 @@
29522952
"redoHistoryCleared": "Wiederholungsverlauf gelöscht",
29532953
"historyDepth": "({count})",
29542954
"none": "Keine",
2955-
"tiktokCardTitleTooLong": "TikTok kürzt diese Nachricht: Sind Schaltflächen angehängt, werden nur die ersten 40 Zeichen gesendet. Kürze die Nachricht oder entferne die Schaltflächen."
2955+
"tiktokCardTitleTooLong": "TikTok kürzt diese Nachricht: Sind Schaltflächen angehängt, werden nur die ersten 40 Zeichen gesendet. Kürze die Nachricht oder entferne die Schaltflächen.",
2956+
"waTemplateMpmNoProducts": "Füge dieser Multi-Produkt-Schaltfläche mindestens ein Produkt hinzu, bevor du veröffentlichst.",
2957+
"waTemplateMpmTooManySections": "Eine Multi-Produkt-Schaltfläche kann höchstens 10 Abschnitte haben. Entferne einige Abschnitte, bevor du veröffentlichst.",
2958+
"waTemplateMpmTooManyProducts": "Eine Multi-Produkt-Schaltfläche kann insgesamt höchstens 30 Produkte haben. Entferne einige Produkte, bevor du veröffentlichst.",
2959+
"waTemplateLtoExpirationRequired": "Wähle ein Ablaufdatum für das zeitlich begrenzte Angebot, bevor du sendest.",
2960+
"waTemplateMpmIncompleteProducts": "Jeder Abschnitt braucht mindestens ein ausgewähltes Produkt — entferne leere Zeilen oder wähle ein Produkt aus."
29562961
},
29572962
"instagram": {
29582963
"description": "Mit dieser Integration können Sie Instagram-Bots erstellen.",
@@ -3359,18 +3364,35 @@
33593364
"flowToken": "Flow-Token",
33603365
"flowTokenPlaceholder": "Flow-Token eingeben",
33613366
"flowNotSynced": "WhatsApp-Flows synchronisieren, um übermittelte Felder zuzuordnen.",
3362-
"catalogProductId": "Produkt-ID",
3363-
"catalogProductIdPlaceholder": "Produkt-Händler-ID eingeben",
3367+
"catalogProductId": "Katalogprodukt",
3368+
"catalogProductIdPlaceholder": "Produkte suchen…",
33643369
"carouselCard": "Karte {index}",
33653370
"limitedTimeOffer": "Ablaufzeit des Angebots",
3366-
"limitedTimeOfferPlaceholder": "Ablaufzeit in Millisekunden eingeben",
3367-
"limitedTimeOfferHelp": "Unix-Zeitstempel in Millisekunden, zu dem das Angebot abläuft",
3371+
"limitedTimeOfferPlaceholder": "Ablaufdatum & -uhrzeit auswählen",
3372+
"limitedTimeOfferHelp": "Die Ablaufzeit wird genau wie ausgewählt gesendet.",
33683373
"location": "Standort",
33693374
"latitude": "Breitengrad",
33703375
"longitude": "Längengrad",
33713376
"locationName": "Standortname (optional)",
33723377
"locationAddress": "Adresse (optional)",
3373-
"enterFormatUrl": "{format}-URL eingeben"
3378+
"enterFormatUrl": "{format}-URL eingeben",
3379+
"catalogUseDefault": "Katalog-Standardminiaturbild verwenden",
3380+
"catalogNotConnectedGuidance": "Verbinde einen Meta-Katalog, um ein Produkt auszuwählen.",
3381+
"catalogSetupLink": "Zu den Katalogeinstellungen",
3382+
"catalogSelectedProductFallback": "Produkt: {retailerId}",
3383+
"limitedTimeOfferBadge": "Läuft ab am {date}",
3384+
"limitedTimeOfferExpiredBadge": "Abgelaufen am {date}",
3385+
"mpmSections": "Produkte",
3386+
"mpmSectionTitlePlaceholder": "Abschnittstitel (optional)",
3387+
"mpmAddSection": "Abschnitt hinzufügen",
3388+
"mpmRemoveSection": "Abschnitt entfernen",
3389+
"mpmAddProduct": "Produkt hinzufügen",
3390+
"mpmRemoveProduct": "Produkt entfernen",
3391+
"mpmNoProductsHelp": "Füge mindestens ein Produkt hinzu, bevor du veröffentlichst.",
3392+
"mpmSectionLimitReached": "Maximal 10 Abschnitte erreicht.",
3393+
"mpmProductLimitReached": "Maximal 30 Produkte erreicht.",
3394+
"mpmProductsSummary": "{productCount} Produkte in {sectionCount} Abschnitten",
3395+
"unsupportedButtonParam": "Dieser Schaltflächentyp wird noch nicht unterstützt."
33743396
}
33753397
},
33763398
"sampleBodyCardContent": {

apps/builder/messages/en.json

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3050,7 +3050,12 @@
30503050
"redoHistoryCleared": "Redo history cleared",
30513051
"historyDepth": "({count})",
30523052
"none": "None",
3053-
"tiktokCardTitleTooLong": "TikTok will cut this message short: with buttons attached, only the first 40 characters are sent. Shorten the message or remove the buttons."
3053+
"tiktokCardTitleTooLong": "TikTok will cut this message short: with buttons attached, only the first 40 characters are sent. Shorten the message or remove the buttons.",
3054+
"waTemplateMpmNoProducts": "Add at least one product to this multi-product button before publishing.",
3055+
"waTemplateMpmTooManySections": "A multi-product button can have at most 10 sections. Remove some sections before publishing.",
3056+
"waTemplateMpmTooManyProducts": "A multi-product button can have at most 30 products in total. Remove some products before publishing.",
3057+
"waTemplateLtoExpirationRequired": "Pick an expiration date for the limited-time offer before sending.",
3058+
"waTemplateMpmIncompleteProducts": "Every section needs at least one selected product — remove empty rows or pick a product for them."
30543059
},
30553060
"instagram": {
30563061
"description": "This integration lets you create Instagram bots.",
@@ -3457,18 +3462,35 @@
34573462
"flowToken": "Flow Token",
34583463
"flowTokenPlaceholder": "Enter flow token",
34593464
"flowNotSynced": "Sync WhatsApp flows to map submitted fields.",
3460-
"catalogProductId": "Product ID",
3461-
"catalogProductIdPlaceholder": "Enter product retailer ID",
3465+
"catalogProductId": "Catalog product",
3466+
"catalogProductIdPlaceholder": "Search products…",
3467+
"catalogUseDefault": "Use catalog default thumbnail",
3468+
"catalogNotConnectedGuidance": "Connect a Meta Catalog to select a product.",
3469+
"catalogSetupLink": "Go to Catalog settings",
3470+
"catalogSelectedProductFallback": "Product: {retailerId}",
34623471
"carouselCard": "Card {index}",
34633472
"limitedTimeOffer": "Offer Expiration Time",
3464-
"limitedTimeOfferPlaceholder": "Enter expiration time in milliseconds",
3465-
"limitedTimeOfferHelp": "Unix timestamp in milliseconds when the offer expires",
3473+
"limitedTimeOfferPlaceholder": "Pick expiration date & time",
3474+
"limitedTimeOfferHelp": "The offer expiration is sent exactly as picked.",
3475+
"limitedTimeOfferBadge": "Expires {date}",
3476+
"limitedTimeOfferExpiredBadge": "Expired {date}",
34663477
"location": "Location",
34673478
"latitude": "Latitude",
34683479
"longitude": "Longitude",
34693480
"locationName": "Location name (optional)",
34703481
"locationAddress": "Address (optional)",
3471-
"enterFormatUrl": "Enter {format} URL"
3482+
"enterFormatUrl": "Enter {format} URL",
3483+
"mpmSections": "Products",
3484+
"mpmSectionTitlePlaceholder": "Section title (optional)",
3485+
"mpmAddSection": "Add section",
3486+
"mpmRemoveSection": "Remove section",
3487+
"mpmAddProduct": "Add product",
3488+
"mpmRemoveProduct": "Remove product",
3489+
"mpmNoProductsHelp": "Add at least one product before publishing.",
3490+
"mpmSectionLimitReached": "Maximum of 10 sections reached.",
3491+
"mpmProductLimitReached": "Maximum of 30 products reached.",
3492+
"mpmProductsSummary": "{productCount} products in {sectionCount} sections",
3493+
"unsupportedButtonParam": "This button type isn't supported yet."
34723494
}
34733495
},
34743496
"sampleBodyCardContent": {

0 commit comments

Comments
 (0)