@mohasinac/appkit 4.12.2 → 4.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/brands/actions.js +7 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +33 -3
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +27 -0
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +172 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +128 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +42 -14
- package/dist/providers/db-firebase/sieve.js +10 -1
- package/dist/seed/site-settings-seed-data.js +19 -1
- package/package.json +1 -1
|
@@ -37,7 +37,12 @@ function brandInputToCategoryFields(input) {
|
|
|
37
37
|
}
|
|
38
38
|
export async function createBrandAction(input) {
|
|
39
39
|
return wrapAction(async () => {
|
|
40
|
-
|
|
40
|
+
// Sellers create brands inline from the product form's brand picker
|
|
41
|
+
// (BrandInlineSelect -> BrandQuickCreateForm); there is no /store/brands
|
|
42
|
+
// page. Mirrors ROLES_STORE_WRITE on the only caller,
|
|
43
|
+
// src/app/api/admin/brands/route.ts. Kept as a gate rather than dropped:
|
|
44
|
+
// this is a "use server" export and is independently callable.
|
|
45
|
+
const actor = await requireRoleUser(["admin", "seller"]);
|
|
41
46
|
const parsed = brandInputSchema.safeParse(input);
|
|
42
47
|
if (!parsed.success)
|
|
43
48
|
throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid brand input");
|
|
@@ -73,7 +78,7 @@ export async function createBrandAction(input) {
|
|
|
73
78
|
},
|
|
74
79
|
seo: { title: parsed.data.name, description: parsed.data.description ?? "", keywords: [parsed.data.name] },
|
|
75
80
|
ancestors: [],
|
|
76
|
-
createdBy:
|
|
81
|
+
createdBy: actor.uid,
|
|
77
82
|
createdAt: new Date(),
|
|
78
83
|
updatedAt: new Date(),
|
|
79
84
|
...fields,
|
|
@@ -13,6 +13,7 @@ import { runHardBanCascade } from "./hardBanCascade";
|
|
|
13
13
|
import { runResetOtpVerification } from "./resetOtpVerification";
|
|
14
14
|
import { runWhatsAppNotify } from "./whatsappNotify";
|
|
15
15
|
import { runNewsletterExport } from "./newsletterExport";
|
|
16
|
+
import { runWhatsAppCatalogImport } from "./whatsappCatalogImport";
|
|
16
17
|
async function runPayoutsWeeklyJob(_payload, ctx) {
|
|
17
18
|
const { payoutsCreated, ordersProcessed, payoutIds } = await runWeeklyPayoutEligibility(ctx);
|
|
18
19
|
return {
|
|
@@ -45,10 +46,14 @@ async function runWhatsAppNotifyJob(payload, ctx) {
|
|
|
45
46
|
async function runNewsletterExportJob(_payload, ctx) {
|
|
46
47
|
return runNewsletterExport(ctx);
|
|
47
48
|
}
|
|
49
|
+
async function runWhatsAppCatalogImportJob(payload, ctx) {
|
|
50
|
+
return runWhatsAppCatalogImport({ storeSlug: String(payload.storeSlug ?? "") }, ctx);
|
|
51
|
+
}
|
|
48
52
|
export const JOB_RUNNERS = {
|
|
49
53
|
payoutsWeekly: runPayoutsWeeklyJob,
|
|
50
54
|
hardBanCascade: runHardBanCascadeJob,
|
|
51
55
|
resetOtpVerification: runResetOtpVerificationJob,
|
|
52
56
|
whatsappNotify: runWhatsAppNotifyJob,
|
|
53
57
|
newsletterExport: runNewsletterExportJob,
|
|
58
|
+
whatsappCatalogImport: runWhatsAppCatalogImportJob,
|
|
54
59
|
};
|
|
@@ -2,6 +2,7 @@ import { normalizeError } from "../../../../errors/normalize";
|
|
|
2
2
|
import { storeRepository, userRepository } from "../../../../repositories";
|
|
3
3
|
import { decryptPii } from "../../../../security/index";
|
|
4
4
|
import { sendWhatsAppBusinessMessage, buildPurchaseAnnouncementMessage, } from "../../../../features/whatsapp-bot/server";
|
|
5
|
+
import { resolveKeys } from "../../../../core/integration-keys";
|
|
5
6
|
async function sendAnnouncement(ctx, toPhone, message, phoneNumberId, accessToken, label, orderId) {
|
|
6
7
|
try {
|
|
7
8
|
const sent = await sendWhatsAppBusinessMessage({
|
|
@@ -44,10 +45,40 @@ async function notifyStoreOwner(ctx, storeId, message, phoneNumberId, accessToke
|
|
|
44
45
|
ctx.logger.error("Store owner lookup failed (non-fatal)", err, { orderId, storeId });
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Seeded credentials are `*_PLACEHOLDER` strings rather than empty, so an
|
|
50
|
+
* emptiness test alone would happily send with a fake token. Mirrors the guard
|
|
51
|
+
* in checkout-value-otp-actions.ts.
|
|
52
|
+
*/
|
|
53
|
+
function usable(value) {
|
|
54
|
+
const v = value?.trim() ?? "";
|
|
55
|
+
return v && !v.includes("PLACEHOLDER") ? v : "";
|
|
56
|
+
}
|
|
57
|
+
async function resolveWhatsAppCredentials(ctx, orderId) {
|
|
58
|
+
let db = {};
|
|
59
|
+
try {
|
|
60
|
+
db = await resolveKeys();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
void normalizeError(err);
|
|
64
|
+
// Firestore unreachable from the Functions runtime — env-only is still a
|
|
65
|
+
// valid configuration, so this is a warning, not a failure.
|
|
66
|
+
ctx.logger.warn("Credential lookup failed — falling back to env", { orderId });
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
phoneNumberId: usable(db.whatsappPhoneNumberId) || usable(ctx.env("WHATSAPP_PHONE_NUMBER_ID")),
|
|
70
|
+
accessToken: usable(db.whatsappCloudApiToken) || usable(ctx.env("WHATSAPP_CLOUD_API_TOKEN")),
|
|
71
|
+
adminNumbersRaw: usable(db.whatsappAdminNotifyNumbers) || usable(ctx.env("WHATSAPP_ADMIN_NOTIFY_NUMBERS")),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
47
74
|
export async function handleOrderCreate(input, ctx) {
|
|
48
75
|
const { orderId, order } = input;
|
|
49
|
-
|
|
50
|
-
|
|
76
|
+
// Credentials resolve DB-first (Admin → Site Settings → WhatsApp) with an env
|
|
77
|
+
// fallback, matching resolveKeys() and every other WhatsApp send path. This
|
|
78
|
+
// read ctx.env() ONLY until 2026-08-22, so credentials saved in Site Settings
|
|
79
|
+
// never reached the order-placed announcement — it silently no-op'd unless the
|
|
80
|
+
// Functions runtime also carried the env vars.
|
|
81
|
+
const { phoneNumberId, accessToken, adminNumbersRaw } = await resolveWhatsAppCredentials(ctx, orderId);
|
|
51
82
|
if (!phoneNumberId || !accessToken) {
|
|
52
83
|
ctx.logger.info("WhatsApp Cloud API not configured — skipping announcement", { orderId });
|
|
53
84
|
return;
|
|
@@ -64,7 +95,6 @@ export async function handleOrderCreate(input, ctx) {
|
|
|
64
95
|
totalAmount: order.totalPrice ?? 0,
|
|
65
96
|
orderId,
|
|
66
97
|
});
|
|
67
|
-
const adminNumbersRaw = ctx.env("WHATSAPP_ADMIN_NOTIFY_NUMBERS") ?? "";
|
|
68
98
|
const adminNumbers = adminNumbersRaw
|
|
69
99
|
.split(",")
|
|
70
100
|
.map((n) => n.trim().replace(/\D/g, ""))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: async WhatsApp Business Catalog import (`whatsappCatalogImport` job).
|
|
3
|
+
*
|
|
4
|
+
* `POST /api/store/whatsapp-settings/catalog-import` used to do this inline:
|
|
5
|
+
* one Meta fetch capped at `limit=250`, then a sequential per-item loop doing
|
|
6
|
+
* up to 2 `findBySlug` reads plus 1 `create` write EACH — a real 10s-timeout
|
|
7
|
+
* risk on Vercel Hobby (CLAUDE.md Rule #6) well before the cap was reached.
|
|
8
|
+
* It also declared `paging.next` in its response type and never followed it,
|
|
9
|
+
* so a catalog larger than 250 items silently imported only the first page.
|
|
10
|
+
*
|
|
11
|
+
* Running here instead buys the Function's 300s budget, which is what makes
|
|
12
|
+
* following `paging.next` safe rather than making the timeout worse.
|
|
13
|
+
*
|
|
14
|
+
* Two efficiency changes vs. the old route:
|
|
15
|
+
* - Existing-slug detection is ONE `findByStore` read into a Set, not 2 reads
|
|
16
|
+
* per item. A seller's WA catalog is populated by pushing their own store's
|
|
17
|
+
* products, so store-scoped lookup is equivalent to the old global
|
|
18
|
+
* `findBySlug` here while costing a single query.
|
|
19
|
+
* - That Set is updated as we create, so duplicate rows within one Meta
|
|
20
|
+
* response can't produce two products.
|
|
21
|
+
*/
|
|
22
|
+
import type { JobContext } from "../runtime/types";
|
|
23
|
+
import type { JobRunResult } from "./jobRunners";
|
|
24
|
+
export interface WhatsAppCatalogImportPayload {
|
|
25
|
+
storeSlug: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function runWhatsAppCatalogImport(payload: WhatsAppCatalogImportPayload, ctx: JobContext): Promise<JobRunResult>;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core: async WhatsApp Business Catalog import (`whatsappCatalogImport` job).
|
|
3
|
+
*
|
|
4
|
+
* `POST /api/store/whatsapp-settings/catalog-import` used to do this inline:
|
|
5
|
+
* one Meta fetch capped at `limit=250`, then a sequential per-item loop doing
|
|
6
|
+
* up to 2 `findBySlug` reads plus 1 `create` write EACH — a real 10s-timeout
|
|
7
|
+
* risk on Vercel Hobby (CLAUDE.md Rule #6) well before the cap was reached.
|
|
8
|
+
* It also declared `paging.next` in its response type and never followed it,
|
|
9
|
+
* so a catalog larger than 250 items silently imported only the first page.
|
|
10
|
+
*
|
|
11
|
+
* Running here instead buys the Function's 300s budget, which is what makes
|
|
12
|
+
* following `paging.next` safe rather than making the timeout worse.
|
|
13
|
+
*
|
|
14
|
+
* Two efficiency changes vs. the old route:
|
|
15
|
+
* - Existing-slug detection is ONE `findByStore` read into a Set, not 2 reads
|
|
16
|
+
* per item. A seller's WA catalog is populated by pushing their own store's
|
|
17
|
+
* products, so store-scoped lookup is equivalent to the old global
|
|
18
|
+
* `findBySlug` here while costing a single query.
|
|
19
|
+
* - That Set is updated as we create, so duplicate rows within one Meta
|
|
20
|
+
* response can't produce two products.
|
|
21
|
+
*/
|
|
22
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
23
|
+
import { storeRepository, productRepository } from "../../../../repositories";
|
|
24
|
+
import { decryptPii } from "../../../../security/index";
|
|
25
|
+
const META_GRAPH_BASE = "https://graph.facebook.com/v20.0";
|
|
26
|
+
const SLUG_PREFIX = "product-";
|
|
27
|
+
const PAGE_SIZE = 250;
|
|
28
|
+
/**
|
|
29
|
+
* Defensive stop. 40 pages x 250 = 10,000 items, far beyond any realistic
|
|
30
|
+
* seller catalog — this exists so a malformed/looping `paging.next` from Meta
|
|
31
|
+
* can't spin until the Function's 300s ceiling kills the job mid-write.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_PAGES = 40;
|
|
34
|
+
/** Meta returns "<amount> <currency>", e.g. "450.00 INR". */
|
|
35
|
+
function parseMetaPrice(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return 0;
|
|
38
|
+
const digits = raw.replace(/[^0-9.]/g, "");
|
|
39
|
+
return Math.round((parseFloat(digits) || 0) * 100) / 100;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The LetItRip slug a Meta item claims to correspond to, if any. Items we
|
|
43
|
+
* pushed carry it in `description` (see catalog-sync's field mapping) and/or
|
|
44
|
+
* `retailer_id`; items authored natively in Meta carry neither.
|
|
45
|
+
*/
|
|
46
|
+
function claimedSlug(item) {
|
|
47
|
+
const description = (item.description ?? "").trim();
|
|
48
|
+
if (description.startsWith(SLUG_PREFIX))
|
|
49
|
+
return description;
|
|
50
|
+
const retailerId = (item.retailer_id ?? "").trim();
|
|
51
|
+
if (retailerId.startsWith(SLUG_PREFIX))
|
|
52
|
+
return retailerId;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
export async function runWhatsAppCatalogImport(payload, ctx) {
|
|
56
|
+
const { storeSlug } = payload;
|
|
57
|
+
const empty = { total: 0, succeeded: 0, skipped: 0, failed: 0 };
|
|
58
|
+
const store = await storeRepository.findBySlug(storeSlug);
|
|
59
|
+
if (!store) {
|
|
60
|
+
return {
|
|
61
|
+
summary: empty,
|
|
62
|
+
succeeded: [],
|
|
63
|
+
skipped: [],
|
|
64
|
+
failed: [{ id: storeSlug, reason: "Store not found" }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const cfg = store.whatsappConfig;
|
|
68
|
+
const token = cfg?.accessToken ? decryptPii(cfg.accessToken) : null;
|
|
69
|
+
// Re-checked here even though the route gates on the same conditions — the
|
|
70
|
+
// job doc outlives the request, and the seller could disconnect in between.
|
|
71
|
+
if (!cfg?.connected || !cfg.catalogId || !token) {
|
|
72
|
+
return {
|
|
73
|
+
summary: empty,
|
|
74
|
+
succeeded: [],
|
|
75
|
+
skipped: [],
|
|
76
|
+
failed: [{ id: storeSlug, reason: "WhatsApp is not connected for this store" }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const existing = await productRepository.findByStore(store.storeSlug);
|
|
80
|
+
const knownSlugs = new Set(existing.map((p) => p.slug).filter((s) => Boolean(s)));
|
|
81
|
+
const imported = [];
|
|
82
|
+
const skipped = [];
|
|
83
|
+
const failed = [];
|
|
84
|
+
let url = `${META_GRAPH_BASE}/${cfg.catalogId}/products` +
|
|
85
|
+
`?fields=name,price,currency,image_url,description,retailer_id&limit=${PAGE_SIZE}`;
|
|
86
|
+
let page = 0;
|
|
87
|
+
let truncated = false;
|
|
88
|
+
while (url) {
|
|
89
|
+
if (page >= MAX_PAGES) {
|
|
90
|
+
truncated = true;
|
|
91
|
+
ctx.logger.warn("whatsappCatalogImport: page cap reached — catalog truncated", {
|
|
92
|
+
storeSlug,
|
|
93
|
+
maxPages: MAX_PAGES,
|
|
94
|
+
importedSoFar: imported.length,
|
|
95
|
+
});
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
page++;
|
|
99
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const body = (await res.json().catch(() => ({})));
|
|
102
|
+
const reason = body.error?.message ?? res.statusText;
|
|
103
|
+
ctx.logger.error("whatsappCatalogImport: Meta fetch failed", null, { storeSlug, page, reason });
|
|
104
|
+
failed.push({ id: `page-${page}`, reason: `Meta fetch failed: ${reason}` });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const body = (await res.json());
|
|
108
|
+
const items = body.data ?? [];
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
const slug = claimedSlug(item);
|
|
111
|
+
if (slug && knownSlugs.has(slug)) {
|
|
112
|
+
skipped.push(item.id);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const input = {
|
|
116
|
+
title: item.name ?? `WhatsApp Import ${item.id}`,
|
|
117
|
+
description: "",
|
|
118
|
+
slug: "",
|
|
119
|
+
listingType: "standard",
|
|
120
|
+
status: "draft",
|
|
121
|
+
price: parseMetaPrice(item.price),
|
|
122
|
+
currency: "INR",
|
|
123
|
+
mainImage: item.image_url ?? "",
|
|
124
|
+
images: [],
|
|
125
|
+
stockQuantity: 1,
|
|
126
|
+
condition: "new",
|
|
127
|
+
storeId: store.id,
|
|
128
|
+
storeName: store.storeName,
|
|
129
|
+
tags: ["whatsapp-import"],
|
|
130
|
+
categorySlugs: [],
|
|
131
|
+
featured: false,
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
const created = await productRepository.create(input);
|
|
135
|
+
// Guard against the same item appearing twice in one Meta response.
|
|
136
|
+
if (slug)
|
|
137
|
+
knownSlugs.add(slug);
|
|
138
|
+
if (created.slug)
|
|
139
|
+
knownSlugs.add(created.slug);
|
|
140
|
+
imported.push(created.id);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
void normalizeError(err);
|
|
144
|
+
failed.push({
|
|
145
|
+
id: item.id,
|
|
146
|
+
reason: err instanceof Error ? err.message : "Product create failed",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
url = body.paging?.next;
|
|
151
|
+
}
|
|
152
|
+
ctx.logger.info("whatsappCatalogImport: complete", {
|
|
153
|
+
storeSlug,
|
|
154
|
+
pages: page,
|
|
155
|
+
imported: imported.length,
|
|
156
|
+
skipped: skipped.length,
|
|
157
|
+
failed: failed.length,
|
|
158
|
+
truncated,
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
summary: {
|
|
162
|
+
total: imported.length + skipped.length + failed.length,
|
|
163
|
+
succeeded: imported.length,
|
|
164
|
+
skipped: skipped.length,
|
|
165
|
+
failed: failed.length,
|
|
166
|
+
},
|
|
167
|
+
succeeded: imported,
|
|
168
|
+
skipped,
|
|
169
|
+
failed,
|
|
170
|
+
data: { imported: imported.length, skipped: skipped.length, pages: page, truncated },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -1272,6 +1272,29 @@ const rawTesterChecklistItems = [
|
|
|
1272
1272
|
description: "Fixed 2026-08-21 — the video schema always required a duration the UI never collected, so ANY product with a directly-uploaded video previously failed server validation invisibly. Duration is now captured client-side for file uploads; YouTube/external sources are exempt since their duration can't be read client-side.",
|
|
1273
1273
|
href: "/store/products/new",
|
|
1274
1274
|
},
|
|
1275
|
+
{
|
|
1276
|
+
key: "media-upload-multiple-images",
|
|
1277
|
+
label: "Uploading several gallery images in one go works — each shows its own progress and all of them survive the save",
|
|
1278
|
+
description: "Select 3-4 images at once. They upload one after another. After saving and reopening the product for editing, every image should still be attached.",
|
|
1279
|
+
href: "/store/products/new",
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
key: "media-upload-remove-image",
|
|
1283
|
+
label: "Removing one gallery image removes only that image, and the removal sticks after save + reload",
|
|
1284
|
+
href: "/store/products/new",
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
key: "media-upload-main-image-crop",
|
|
1288
|
+
label: "The main product image's crop tool opens, the 1:1 aspect lock works, and the cropped result is what actually gets saved",
|
|
1289
|
+
description: "Not the uncropped original — check the saved product's main image on the public detail page.",
|
|
1290
|
+
href: "/store/products/new",
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
key: "media-upload-video-both-sources-render",
|
|
1294
|
+
label: "A video attached by file upload AND a video attached via the YouTube tab both play on the public product page",
|
|
1295
|
+
description: "Two separate products, one per source. A raw file renders a native player; a YouTube URL renders an embedded player. Neither should show \"No video with supported format and MIME type found\".",
|
|
1296
|
+
href: "/store/products/new",
|
|
1297
|
+
},
|
|
1275
1298
|
{ key: "seller-quick-add-drawer-flips", label: "With Left-hand mode ON, the seller's quick-add-listing side drawer opens from the left instead of the right" },
|
|
1276
1299
|
],
|
|
1277
1300
|
},
|
|
@@ -1415,6 +1438,24 @@ const rawTesterChecklistItems = [
|
|
|
1415
1438
|
href: "/store/coupons/new",
|
|
1416
1439
|
description: "Create a coupon restricted to one category, then as a buyer put a non-matching item from that store in the cart and try the code — it must be refused. Add a matching item and confirm the discount covers only the matching item's value.",
|
|
1417
1440
|
},
|
|
1441
|
+
{
|
|
1442
|
+
key: "seller-coupon-cannot-be-site-wide",
|
|
1443
|
+
label: "A seller has no way to create a site-wide/platform coupon — the form offers no scope choice and the created coupon only ever discounts their own store",
|
|
1444
|
+
href: "/store/coupons/new",
|
|
1445
|
+
description: "By design: scope is resolved from the logged-in seller, not chosen. Verify as a buyer that a seller coupon leaves a second store's items in the same cart at full price. Only an admin can create a platform-wide coupon.",
|
|
1446
|
+
},
|
|
1447
|
+
{
|
|
1448
|
+
key: "seller-coupon-second-for-same-store-rejected",
|
|
1449
|
+
label: "Applying a second seller coupon for the SAME store replaces or refuses — you can never have two coupons from one store active at once",
|
|
1450
|
+
href: "/cart",
|
|
1451
|
+
description: "The rule is one seller coupon per store, plus at most one admin coupon overall. Try applying two of store-beyblade-arena's codes back to back.",
|
|
1452
|
+
},
|
|
1453
|
+
{
|
|
1454
|
+
key: "seller-coupon-stacks-with-admin-coupon",
|
|
1455
|
+
label: "One seller coupon and one admin (site-wide) coupon apply together, and the order total reflects both",
|
|
1456
|
+
href: "/cart",
|
|
1457
|
+
description: "Try ARENA25 (store-beyblade-arena) together with a platform coupon such as FREESHIP499. Both should stay applied and the breakdown should list both.",
|
|
1458
|
+
},
|
|
1418
1459
|
{ key: "seller-bundles-crud", label: "Seller can create, edit, and list bundles/grouped listings", href: "/store/bundles" },
|
|
1419
1460
|
{ key: "seller-classified-crud", label: "Seller can create, edit, and list classified listings", href: "/store/classified" },
|
|
1420
1461
|
{ key: "seller-digitalcodes-crud", label: "Seller can create, edit, and list digital-code listings", href: "/store/digital-codes" },
|
|
@@ -1449,6 +1490,58 @@ const rawTesterChecklistItems = [
|
|
|
1449
1490
|
href: "/store/sublisting-categories",
|
|
1450
1491
|
},
|
|
1451
1492
|
{ key: "seller-listing-templates-crud", label: "Seller can create, edit, and reuse listing templates when creating a new product", href: "/store/listing-templates" },
|
|
1493
|
+
{
|
|
1494
|
+
key: "seller-category-inline-create",
|
|
1495
|
+
label: "On the product form, the Category picker's \"+ Create new category\" opens a drawer, saves, and auto-selects the new category",
|
|
1496
|
+
description: "The inline drawer is reachable from the category picker on the product form. Type a name, save, and confirm the drawer closes and the new category is already selected in the picker — you should not have to search for it.",
|
|
1497
|
+
href: "/store/products/new",
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
key: "seller-category-inline-create-persists",
|
|
1501
|
+
label: "A category created inline is still there after reload — reopen the picker on a fresh product form and search for it by name",
|
|
1502
|
+
description: "Proves the category actually persisted to Firestore rather than only existing in the form's local state.",
|
|
1503
|
+
href: "/store/products/new",
|
|
1504
|
+
},
|
|
1505
|
+
{
|
|
1506
|
+
key: "seller-category-inline-create-duplicate-rejected",
|
|
1507
|
+
label: "Creating a category with a name that already exists shows a readable \"already exists\" error, not a generic failure or a silent no-op",
|
|
1508
|
+
description: "Slug is derived from the name, so a duplicate name collides. Expect a clear message in the drawer.",
|
|
1509
|
+
href: "/store/products/new",
|
|
1510
|
+
},
|
|
1511
|
+
],
|
|
1512
|
+
},
|
|
1513
|
+
{
|
|
1514
|
+
pageKey: "seller-custom-brands",
|
|
1515
|
+
pageLabel: "Seller — Custom Brands",
|
|
1516
|
+
// The ONLY way a seller creates a brand is the inline picker on the
|
|
1517
|
+
// product form — there is no /store/brands page.
|
|
1518
|
+
href: "/store/products/new",
|
|
1519
|
+
cases: [
|
|
1520
|
+
{
|
|
1521
|
+
key: "seller-brand-inline-create",
|
|
1522
|
+
label: "On the product form, the Brand picker's \"+ Create new brand\" saves successfully and auto-selects the new brand",
|
|
1523
|
+
description: "Fixed 2026-08-22 — this previously failed with a permissions error for every seller: the API route allowed sellers but the underlying action still demanded an admin role. Confirm a seller (not an admin) can complete this end to end.",
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
key: "seller-brand-inline-create-persists",
|
|
1527
|
+
label: "A brand created inline is still there after reload — reopen the Brand picker on a fresh product form and search for it by name",
|
|
1528
|
+
description: "Proves the brand persisted rather than only existing in form state.",
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
key: "seller-brand-appears-on-public-brands-page",
|
|
1532
|
+
label: "A newly created brand appears on the public /brands page",
|
|
1533
|
+
description: "Brands are stored as category rows discriminated by categoryType:\"brand\", and the public page filters on exactly that. If the brand saves but never shows here, the discriminator wasn't written.",
|
|
1534
|
+
href: "/brands",
|
|
1535
|
+
},
|
|
1536
|
+
{
|
|
1537
|
+
key: "seller-brand-inline-create-duplicate-rejected",
|
|
1538
|
+
label: "Creating a brand whose name matches an existing brand shows a readable \"already exists\" error rather than creating a second copy",
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
key: "seller-brand-product-saves-with-new-brand",
|
|
1542
|
+
label: "A product saved with an inline-created brand keeps that brand after save — reopen the product for editing and the Brand field still shows it",
|
|
1543
|
+
description: "Catches the case where the brand is created but its id never makes it onto the product document.",
|
|
1544
|
+
},
|
|
1452
1545
|
],
|
|
1453
1546
|
},
|
|
1454
1547
|
{
|
|
@@ -1483,6 +1576,24 @@ const rawTesterChecklistItems = [
|
|
|
1483
1576
|
description: "Added 2026-08-21. The store's WhatsApp access token is encrypted before storage by the same mechanism as the admin Integrations keys, and that mechanism was failing in production — so this save path was broken too, in a place nobody would think to check. Enter a throwaway token value, save, reload the page, and confirm you got a success toast and the field shows a masked value rather than an error or a blank field.",
|
|
1484
1577
|
href: "/store/whatsapp",
|
|
1485
1578
|
},
|
|
1579
|
+
{
|
|
1580
|
+
key: "seller-whatsapp-import-runs-in-background",
|
|
1581
|
+
label: "\"Import from WhatsApp\" returns immediately and reports its result when the background job finishes — the page never hangs waiting on it",
|
|
1582
|
+
description: "Changed 2026-08-22 — the import used to run inline and could time out on a large catalog, silently importing only the first 250 items. It now runs as a background job, so expect an immediate \"import started\" response and a result toast a few moments later.",
|
|
1583
|
+
href: "/store/whatsapp",
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
key: "seller-whatsapp-import-skips-already-synced",
|
|
1587
|
+
label: "Re-running \"Import from WhatsApp\" a second time imports nothing new — everything is reported as already synced",
|
|
1588
|
+
description: "Items pushed from here carry their LetItRip slug, so a repeat import must not create duplicate draft products. Run it twice and compare the counts.",
|
|
1589
|
+
href: "/store/whatsapp",
|
|
1590
|
+
},
|
|
1591
|
+
{
|
|
1592
|
+
key: "seller-whatsapp-push-product-link-opens",
|
|
1593
|
+
label: "A product pushed to the WhatsApp catalog links back to a working, full product URL — not a broken relative link",
|
|
1594
|
+
description: "Fixed 2026-08-22 — the pushed link was a relative path Meta rejects. Open the item in your WhatsApp catalog and tap through to confirm it lands on the live product page.",
|
|
1595
|
+
href: "/store/whatsapp",
|
|
1596
|
+
},
|
|
1486
1597
|
{ key: "seller-reviews-response", label: "Seller can view and respond to product reviews", href: "/store/reviews" },
|
|
1487
1598
|
],
|
|
1488
1599
|
},
|
|
@@ -2602,6 +2713,23 @@ const rawTesterChecklistItems = [
|
|
|
2602
2713
|
href: "/admin/site",
|
|
2603
2714
|
cases: [
|
|
2604
2715
|
{ key: "site-settings-admin", label: "Admin site settings page saves correctly", href: "/admin/site" },
|
|
2716
|
+
{
|
|
2717
|
+
key: "whatsapp-credentials-persist",
|
|
2718
|
+
label: "Site Settings → WhatsApp saves the Phone Number ID, Cloud API token, admin notify numbers, template language and all 6 template names — and they survive a reload",
|
|
2719
|
+
description: "The token field is masked on reload (shows dots, not the raw value) — that is correct. Confirm by changing an unrelated field, saving again, and verifying the token still works rather than being overwritten with the mask.",
|
|
2720
|
+
href: "/admin/site",
|
|
2721
|
+
},
|
|
2722
|
+
{
|
|
2723
|
+
key: "whatsapp-channel-toggle-persists",
|
|
2724
|
+
label: "Site Settings → Notifications: the WhatsApp channel enable toggle, minimum priority, and the checkout OTP switch all persist after save + reload",
|
|
2725
|
+
href: "/admin/site",
|
|
2726
|
+
},
|
|
2727
|
+
{
|
|
2728
|
+
key: "whatsapp-order-announcement-fires",
|
|
2729
|
+
label: "Placing an order sends the WhatsApp announcement to the configured admin numbers using credentials saved in Site Settings — no env var required",
|
|
2730
|
+
description: "Fixed 2026-08-22 — the order-placed announcement previously read environment variables ONLY, so credentials entered in Site Settings were ignored and it silently did nothing. Needs real Meta credentials configured to verify.",
|
|
2731
|
+
href: "/admin/site",
|
|
2732
|
+
},
|
|
2605
2733
|
{
|
|
2606
2734
|
key: "auction-bid-tiers-admin",
|
|
2607
2735
|
label: "Admin can add, edit, and remove bid-increment tiers in Site Settings → Auction Config, and the changes persist after saving and reloading the page",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
3
|
+
import { useApiMutation, useBulkEvent, RTDB_PATHS } from "@mohasinac/appkit/client";
|
|
4
4
|
import React from "react";
|
|
5
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
5
6
|
const __P = {
|
|
6
7
|
p3: "p-[var(--appkit-space-3)]",
|
|
7
8
|
};
|
|
@@ -136,19 +137,46 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
|
|
|
136
137
|
showToast(msg, "error");
|
|
137
138
|
},
|
|
138
139
|
});
|
|
139
|
-
// Catalog import
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
// Catalog import (pull: WhatsApp → site). Runs as the `whatsappCatalogImport`
|
|
141
|
+
// async job — the route only enqueues, so the result arrives over the
|
|
142
|
+
// useBulkEvent RTDB channel rather than in the POST response.
|
|
143
|
+
const bulkEvent = useBulkEvent({ rtdbPath: RTDB_PATHS.BULK_EVENTS });
|
|
144
|
+
const [importing, setImporting] = React.useState(false);
|
|
145
|
+
const startImport = async () => {
|
|
146
|
+
setImporting(true);
|
|
147
|
+
try {
|
|
148
|
+
const res = (await apiClient.post(WHATSAPP_SELLER_ENDPOINTS.CATALOG_IMPORT, {}));
|
|
149
|
+
const { jobId, customToken } = res.data ?? {};
|
|
150
|
+
if (jobId && customToken) {
|
|
151
|
+
bulkEvent.subscribe(jobId, customToken);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
setImporting(false);
|
|
155
|
+
showToast("Failed to start catalog import.", "error");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
void normalizeError(err);
|
|
160
|
+
setImporting(false);
|
|
161
|
+
showToast(err instanceof Error ? err.message : "Import failed", "error");
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
if (bulkEvent.status === "success") {
|
|
166
|
+
setImporting(false);
|
|
167
|
+
const summary = bulkEvent.result?.summary;
|
|
168
|
+
const imported = summary?.succeeded ?? 0;
|
|
169
|
+
const skipped = summary?.skipped ?? 0;
|
|
170
|
+
showToast(`Imported ${imported} product${imported !== 1 ? "s" : ""} from WhatsApp (${skipped} already synced)`, "success");
|
|
145
171
|
void queryClient.invalidateQueries({ queryKey: ["store", "whatsapp-settings"] });
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
172
|
+
void queryClient.invalidateQueries({ queryKey: ["store", "products"] });
|
|
173
|
+
}
|
|
174
|
+
else if (bulkEvent.status === "failed" || bulkEvent.status === "timeout") {
|
|
175
|
+
setImporting(false);
|
|
176
|
+
showToast(bulkEvent.error ?? "Catalog import failed.", "error");
|
|
177
|
+
}
|
|
178
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
179
|
+
}, [bulkEvent.status]);
|
|
152
180
|
if (!hasCapability) {
|
|
153
181
|
return (_jsx(Div, { className: "max-w-xl mx-auto", paddingY: "y-xl", paddingX: "x-md", children: _jsxs(Alert, { variant: "warning", children: [_jsx(Text, { weight: "medium", children: "WhatsApp catalog sync is not enabled for your store." }), _jsx(Text, { className: "mt-1", size: "sm", children: "Contact LetItRip support to request access to the WhatsApp Business integration." })] }) }));
|
|
154
182
|
}
|
|
@@ -162,7 +190,7 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
|
|
|
162
190
|
}) })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "primary", children: "Connection" }), isLoading ? null : cfg?.connected ? (_jsx(Badge, { variant: "success", children: "Connected" })) : (_jsx(Badge, { variant: "default", children: "Not configured" }))] }), _jsxs(Form, { onSubmit: (e) => {
|
|
163
191
|
e.preventDefault();
|
|
164
192
|
saveMutation.mutate();
|
|
165
|
-
}, spacing: "md", children: [_jsx(Input, { label: "WhatsApp Business Phone Number", value: phoneNumber, onChange: (e) => setPhoneNumber(e.target.value), placeholder: "919876543210", helperText: "Digits only, include country code (e.g. 91 for India)" }), _jsx(Input, { label: "WABA ID (WhatsApp Business Account ID)", value: wabaId, onChange: (e) => setWabaId(e.target.value), placeholder: "123456789012345", helperText: "From Meta Business Manager \u2192 WhatsApp Accounts" }), _jsx(Input, { label: "Catalog ID", value: catalogId, onChange: (e) => setCatalogId(e.target.value), placeholder: "987654321098765", helperText: "From Meta Commerce Manager \u2192 Catalogs" }), _jsx(MaskedTokenInput, { label: "System User Access Token", value: accessToken, onChange: setAccessToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived token with WhatsApp Business permissions" }), _jsx(FormActions, { align: "right", children: _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: saveMutation.isPending, children: saveMutation.isPending ? "Saving…" : ACTIONS.STORE["whatsapp-connect"].label }) })] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-4", color: "primary", size: "sm", weight: "semibold", children: "Catalog Sync" }), _jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", color: "muted", children: "Enable catalog sync" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, your published standard products can be synced to WhatsApp." })] }), _jsx(Toggle, { checked: syncEnabled, onChange: setSyncEnabled, disabled: !cfg?.connected })] }), cfg?.lastCatalogSyncAt && (_jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", align: "center", className: "mb-3", children: [_jsxs(Text, { children: ["Last sync: ", new Date(cfg.lastCatalogSyncAt).toLocaleString("en-IN")] }), cfg.lastSyncCount !== undefined && _jsxs(Text, { children: ["\u00B7 ", cfg.lastSyncCount, " products"] }), cfg.lastSyncStatus && (_jsx(Badge, { variant: STATUS_VARIANT[cfg.lastSyncStatus] ?? "default", children: cfg.lastSyncStatus }))] })), _jsx(Text, { className: "mb-3", color: "faint", size: "xs", children: "Only published standard products are synced. Auctions and pre-orders are excluded. Batches of up to 50 products per call." }), _jsxs(Row, { gap: "3", children: [_jsx(Button, { onClick: () => syncMutation.mutate(), isLoading: syncMutation.isPending, disabled: !cfg?.connected || !syncEnabled || syncMutation.isPending, variant: "secondary", children: syncMutation.isPending ? "Syncing…" : ACTIONS.STORE["whatsapp-catalog-sync"].label }), _jsx(Button, { onClick: () =>
|
|
193
|
+
}, spacing: "md", children: [_jsx(Input, { label: "WhatsApp Business Phone Number", value: phoneNumber, onChange: (e) => setPhoneNumber(e.target.value), placeholder: "919876543210", helperText: "Digits only, include country code (e.g. 91 for India)" }), _jsx(Input, { label: "WABA ID (WhatsApp Business Account ID)", value: wabaId, onChange: (e) => setWabaId(e.target.value), placeholder: "123456789012345", helperText: "From Meta Business Manager \u2192 WhatsApp Accounts" }), _jsx(Input, { label: "Catalog ID", value: catalogId, onChange: (e) => setCatalogId(e.target.value), placeholder: "987654321098765", helperText: "From Meta Commerce Manager \u2192 Catalogs" }), _jsx(MaskedTokenInput, { label: "System User Access Token", value: accessToken, onChange: setAccessToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived token with WhatsApp Business permissions" }), _jsx(FormActions, { align: "right", children: _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: saveMutation.isPending, children: saveMutation.isPending ? "Saving…" : ACTIONS.STORE["whatsapp-connect"].label }) })] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-4", color: "primary", size: "sm", weight: "semibold", children: "Catalog Sync" }), _jsxs(Row, { justify: "between", align: "center", className: "mb-4", children: [_jsxs(Div, { children: [_jsx(Text, { size: "sm", color: "muted", children: "Enable catalog sync" }), _jsx(Text, { size: "xs", color: "muted", children: "When enabled, your published standard products can be synced to WhatsApp." })] }), _jsx(Toggle, { checked: syncEnabled, onChange: setSyncEnabled, disabled: !cfg?.connected })] }), cfg?.lastCatalogSyncAt && (_jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", align: "center", className: "mb-3", children: [_jsxs(Text, { children: ["Last sync: ", new Date(cfg.lastCatalogSyncAt).toLocaleString("en-IN")] }), cfg.lastSyncCount !== undefined && _jsxs(Text, { children: ["\u00B7 ", cfg.lastSyncCount, " products"] }), cfg.lastSyncStatus && (_jsx(Badge, { variant: STATUS_VARIANT[cfg.lastSyncStatus] ?? "default", children: cfg.lastSyncStatus }))] })), _jsx(Text, { className: "mb-3", color: "faint", size: "xs", children: "Only published standard products are synced. Auctions and pre-orders are excluded. Batches of up to 50 products per call." }), _jsxs(Row, { gap: "3", children: [_jsx(Button, { onClick: () => syncMutation.mutate(), isLoading: syncMutation.isPending, disabled: !cfg?.connected || !syncEnabled || syncMutation.isPending, variant: "secondary", children: syncMutation.isPending ? "Syncing…" : ACTIONS.STORE["whatsapp-catalog-sync"].label }), _jsx(Button, { onClick: () => void startImport(), isLoading: importing, disabled: !cfg?.connected || !syncEnabled || importing, variant: "secondary", children: importing ? "Importing…" : ACTIONS.STORE["whatsapp-catalog-import"].label })] }), _jsxs(Text, { className: "mt-3", color: "faint", size: "xs", children: [_jsx(Span, { weight: "bold", children: "Push" }), " sends your published standard products to WhatsApp.", " ", _jsx(Span, { weight: "bold", children: "Import" }), " creates draft products from your WhatsApp catalog. Products are matched by slug in the description field."] })] }), _jsxs(Section, { className: CLS_SECTION_CARD, children: [_jsx(Heading, { level: 2, className: "mb-2", color: "primary", size: "sm", weight: "semibold", children: "Purchase Announcement Preview" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "This message is sent automatically to your phone and the platform admin when a new order is placed." }), _jsx(Div, { textSize: "sm", className: "font-mono", color: "primary", surface: "muted", padding: "inline", rounded: "lg", children: buildPurchaseAnnouncementMessage({
|
|
166
194
|
buyerName: "Ravi K.",
|
|
167
195
|
firstItemName: "Charizard PSA 9",
|
|
168
196
|
additionalItemCount: 2,
|
|
@@ -136,7 +136,16 @@ export async function applySieveToFirestore(params) {
|
|
|
136
136
|
const merged = { ...SIEVE_DEFAULTS, ...rest };
|
|
137
137
|
const effective = withAliasesExpanded(model, aliases);
|
|
138
138
|
const processor = new SieveProcessorBase({
|
|
139
|
-
adapter
|
|
139
|
+
// MUST be the enhanced adapter, not the stock one. This is the path
|
|
140
|
+
// `BaseRepository.sieveQuery` delegates to, so it is what ProductRepository
|
|
141
|
+
// and most other repositories actually run — only `FirebaseSieveRepository`
|
|
142
|
+
// used the enhanced adapter, and nothing extends that. With the stock
|
|
143
|
+
// adapter a same-field OR group (`listingType==art|stickers`, i.e. every
|
|
144
|
+
// multi-select facet in the app) became a `whereOr` call the Admin SDK does
|
|
145
|
+
// not implement; `throwExceptions: false` then swallowed it and the clause
|
|
146
|
+
// vanished, returning the ENTIRE collection instead of the selected subset.
|
|
147
|
+
// See __tests__/sieve-or-group.test.ts.
|
|
148
|
+
adapter: createEnhancedFirebaseAdapter(),
|
|
140
149
|
autoLoadConfig: false,
|
|
141
150
|
options: merged,
|
|
142
151
|
fields,
|
|
@@ -523,9 +523,27 @@ export const siteSettingsSeedData = {
|
|
|
523
523
|
razorpayWebhookSecret: "webhook_PLACEHOLDER",
|
|
524
524
|
resendApiKey: "",
|
|
525
525
|
whatsappApiKey: "wa_PLACEHOLDER",
|
|
526
|
+
// Real Meta secrets — deliberately left empty. With these unset the order
|
|
527
|
+
// announcement logs "not configured" and skips cleanly; a placeholder
|
|
528
|
+
// string here would instead be treated as a usable token and fail on send.
|
|
529
|
+
// Enter the real values in Admin → Site Settings → WhatsApp.
|
|
526
530
|
whatsappPhoneNumberId: "",
|
|
527
531
|
whatsappCloudApiToken: "",
|
|
528
|
-
|
|
532
|
+
// The platform's own WhatsApp number, digits-only with country code
|
|
533
|
+
// (+91 8919665811). Same number as contact.whatsappNumber above.
|
|
534
|
+
whatsappAdminNotifyNumbers: "918919665811",
|
|
535
|
+
// Default Meta message-template names. These MUST be created and approved
|
|
536
|
+
// in Meta Business Manager under exactly these names — business-initiated
|
|
537
|
+
// sends outside the 24h customer-service window are rejected without an
|
|
538
|
+
// approved template. Names match the notification types they serve, see
|
|
539
|
+
// WHATSAPP_TEMPLATE_BY_TYPE in features/admin/actions/notification-actions.ts.
|
|
540
|
+
whatsappTemplateOrderPlaced: "order_placed",
|
|
541
|
+
whatsappTemplateOrderConfirmed: "order_confirmed",
|
|
542
|
+
whatsappTemplateOrderShipped: "order_shipped",
|
|
543
|
+
whatsappTemplateOrderDelivered: "order_delivered",
|
|
544
|
+
whatsappTemplateOrderCancelled: "order_cancelled",
|
|
545
|
+
whatsappTemplateRefundInitiated: "refund_initiated",
|
|
546
|
+
whatsappTemplateLanguage: "en",
|
|
529
547
|
metaAppId: "meta_app_PLACEHOLDER",
|
|
530
548
|
metaAppSecret: "meta_secret_PLACEHOLDER",
|
|
531
549
|
metaPageAccessToken: "meta_page_token_PLACEHOLDER",
|