@mohasinac/appkit 4.63.3 → 4.63.5

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.
Files changed (179) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  3. package/dist/_internal/server/features/checkout/actions.js +22 -5
  4. package/dist/_internal/server/features/orders/adapters.js +3 -1
  5. package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
  6. package/dist/_internal/server/features/promotions/adapters.js +43 -0
  7. package/dist/_internal/server/features/promotions/data.d.ts +26 -0
  8. package/dist/_internal/server/features/promotions/data.js +45 -0
  9. package/dist/_internal/server/features/promotions/index.d.ts +2 -1
  10. package/dist/_internal/server/features/promotions/index.js +2 -1
  11. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  12. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  13. package/dist/_internal/server/features/stores/adapters.js +1 -0
  14. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  15. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  16. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  17. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  18. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  19. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  20. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  21. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  22. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  23. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  24. package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
  25. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  26. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  27. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  28. package/dist/_internal/server/notifications/channel-health.js +2 -2
  29. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  30. package/dist/_internal/server/notifications/send-guard.js +4 -2
  31. package/dist/_internal/shared/actions/action-registry.js +28 -0
  32. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  33. package/dist/_internal/shared/checkout/order-math.js +1 -1
  34. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  35. package/dist/_internal/shared/features/checkout/config.js +2 -0
  36. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  37. package/dist/_internal/shared/features/messaging/config.js +4 -1
  38. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  39. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  40. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  41. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  42. package/dist/_internal/shared/fees/calculator.js +7 -0
  43. package/dist/cli/index.js +2 -0
  44. package/dist/client.d.ts +2 -2
  45. package/dist/client.js +1 -1
  46. package/dist/constants/api-endpoints.d.ts +15 -0
  47. package/dist/constants/api-endpoints.js +8 -0
  48. package/dist/constants/field-names.d.ts +4 -0
  49. package/dist/constants/field-names.js +4 -0
  50. package/dist/constants/notification-types.d.ts +1 -1
  51. package/dist/constants/notification-types.js +1 -1
  52. package/dist/core/integration-keys.d.ts +5 -0
  53. package/dist/core/integration-keys.js +10 -0
  54. package/dist/features/about/components/FeesView.js +6 -0
  55. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  56. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  57. package/dist/features/account/components/UserSidebar.js +1 -1
  58. package/dist/features/account/types/index.d.ts +2 -0
  59. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  60. package/dist/features/admin/actions/notification-actions.js +154 -9
  61. package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
  62. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  63. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  64. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  65. package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
  66. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  67. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  68. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  69. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  70. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  71. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  72. package/dist/features/admin/schemas/firestore.js +8 -3
  73. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  74. package/dist/features/auth/checkout-value-otp.js +1 -1
  75. package/dist/features/auth/permissions/constants.d.ts +2 -2
  76. package/dist/features/auth/permissions/constants.js +2 -1
  77. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  78. package/dist/features/blog/schemas/blog-form.js +14 -0
  79. package/dist/features/blog/schemas/firestore.d.ts +17 -0
  80. package/dist/features/blog/schemas/firestore.js +1 -0
  81. package/dist/features/blog/schemas/index.d.ts +6 -0
  82. package/dist/features/blog/schemas/index.js +3 -0
  83. package/dist/features/blog/types/index.d.ts +2 -0
  84. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  85. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  86. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  87. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  88. package/dist/features/cart/repository/cart.repository.js +2 -1
  89. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  90. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  91. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  92. package/dist/features/events/components/AdminEventEditorView.js +64 -9
  93. package/dist/features/events/components/EventOfferCard.js +3 -18
  94. package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
  95. package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
  96. package/dist/features/events/schemas/event-form.d.ts +10 -0
  97. package/dist/features/events/schemas/event-form.js +19 -0
  98. package/dist/features/events/schemas/firestore.d.ts +68 -0
  99. package/dist/features/events/schemas/firestore.js +25 -0
  100. package/dist/features/events/schemas/index.d.ts +7 -0
  101. package/dist/features/events/schemas/index.js +5 -0
  102. package/dist/features/events/types/index.d.ts +4 -0
  103. package/dist/features/homepage/lib/section-renderer.js +1 -1
  104. package/dist/features/orders/actions/refund-actions.js +1 -0
  105. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  106. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  107. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  108. package/dist/features/orders/repository/orders.repository.js +2 -0
  109. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  110. package/dist/features/orders/types/index.d.ts +5 -2
  111. package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
  112. package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
  113. package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
  114. package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
  115. package/dist/features/promotions/components/index.d.ts +4 -0
  116. package/dist/features/promotions/components/index.js +2 -0
  117. package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
  118. package/dist/features/promotions/constants/attached-coupons.js +24 -0
  119. package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
  120. package/dist/features/promotions/repository/coupons.repository.js +39 -0
  121. package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
  122. package/dist/features/promotions/schemas/attached-coupons.js +66 -0
  123. package/dist/features/search/action-index/settings-entries.js +9 -0
  124. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  125. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  126. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  127. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  128. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
  129. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  130. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  131. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  132. package/dist/features/stores/repository/store.repository.js +29 -1
  133. package/dist/features/stores/schemas/firestore.d.ts +16 -6
  134. package/dist/features/stores/schemas/firestore.js +3 -6
  135. package/dist/features/stores/schemas/index.d.ts +86 -0
  136. package/dist/features/stores/schemas/index.js +14 -0
  137. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  138. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  139. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  140. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  141. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  142. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  143. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  144. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  145. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  146. package/dist/features/whatsapp-bot/components/index.js +2 -0
  147. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  148. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  149. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  150. package/dist/features/whatsapp-bot/index.js +3 -0
  151. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  152. package/dist/features/whatsapp-bot/manifest.js +13 -0
  153. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  154. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  155. package/dist/features/whatsapp-bot/server.js +7 -0
  156. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  157. package/dist/features/whatsapp-bot/types/index.js +1 -0
  158. package/dist/index.d.ts +23 -6
  159. package/dist/index.js +38 -12
  160. package/dist/next/routing/route-map.d.ts +3 -0
  161. package/dist/next/routing/route-map.js +1 -0
  162. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  163. package/dist/providers/db-firebase/base.repository.js +1 -1
  164. package/dist/repositories/index.d.ts +1 -1
  165. package/dist/repositories/index.js +1 -1
  166. package/dist/schemas/registry.d.ts +121 -0
  167. package/dist/schemas/registry.js +2 -1
  168. package/dist/security/pii-encrypt.js +1 -1
  169. package/dist/server-entry.d.ts +1 -0
  170. package/dist/server-entry.js +2 -0
  171. package/dist/server.d.ts +11 -2
  172. package/dist/server.js +26 -2
  173. package/dist/styles.css +1 -1
  174. package/dist/tailwind-utilities.css +1 -1
  175. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  176. package/dist/ui/components/NavigationLoader.js +79 -30
  177. package/package.json +1 -1
  178. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  179. package/dist/features/orders/utils/order-announcement-message.js +0 -16
package/dist/client.d.ts CHANGED
@@ -545,7 +545,7 @@ export type { FeeDerivation, FeeDerivations } from "./_internal/shared/fees/expl
545
545
  export type { BuyerFacingFees, BuyerEmiSettings, } from "./_internal/shared/features/site-settings/fees";
546
546
  export type { PublicSiteSettings } from "./_internal/shared/features/site-settings/types";
547
547
  export { computeCodHandlingFee } from "./_internal/shared/fees/calculator";
548
- export type { CodHandlingFeeRates, GiftWrapFeeRates, ShipmentProtectionFeeRates, } from "./_internal/shared/fees/calculator";
548
+ export type { CodHandlingFeeRates, WhatsAppNotifyFeeRates, GiftWrapFeeRates, ShipmentProtectionFeeRates, } from "./_internal/shared/fees/calculator";
549
549
  export { FAQPageContent } from "./features/faq/index";
550
550
  export type { FAQCategory, FAQCategoryItem, FAQSortOption } from "./features/faq/index";
551
551
  export { CategoryInlineSelect } from "./features/seller/components/CategoryInlineSelect";
@@ -571,7 +571,7 @@ export type { AdCreativeFormValues } from "./features/ads/schemas/creative-form"
571
571
  export { adminStoreUpdateSchema, announcementBarSchema, } from "./features/admin/schemas/admin-editor-forms";
572
572
  export { adminOrderUpdateSchema, employeeInviteSchema, hardBanReasonSchema, featureFlagsUpdateSchema, adminAdFormSchema, } from "./features/admin/schemas/admin-ops-forms";
573
573
  export { changeEmailSchema } from "./features/account/schemas/index";
574
- export { adminUserUpdateSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form";
574
+ export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form";
575
575
  export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form";
576
576
  export { refineAddressPostal, addressFormSchema, addressUpdateSchema, adminAddressCreateSchema, adminAddressUpdateSchema, addressUnbanRequestSchema, } from "./features/addresses/schemas/address-form";
577
577
  export type { AddressFormValues, AddressUpdateValues, AdminAddressCreateValues, } from "./features/addresses/schemas/address-form";
package/dist/client.js CHANGED
@@ -619,7 +619,7 @@ export { adCreativeFormSchema } from "./features/ads/schemas/creative-form.js";
619
619
  export { adminStoreUpdateSchema, announcementBarSchema, } from "./features/admin/schemas/admin-editor-forms.js";
620
620
  export { adminOrderUpdateSchema, employeeInviteSchema, hardBanReasonSchema, featureFlagsUpdateSchema, adminAdFormSchema, } from "./features/admin/schemas/admin-ops-forms.js";
621
621
  export { changeEmailSchema } from "./features/account/schemas/index.js";
622
- export { adminUserUpdateSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form.js";
622
+ export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form.js";
623
623
  export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form.js";
624
624
  // The ONE address shape and the ONE postal rule (W5 / D19). Eleven field
625
625
  // shapes and fifteen disagreeing postal rules collapsed to these.
@@ -626,6 +626,11 @@ export declare const SCAMMER_ENDPOINTS: {
626
626
  export declare const BEFORE_AFTER_ENDPOINTS: {
627
627
  readonly LIST: "/api/before-after";
628
628
  };
629
+ export declare const WHATSAPP_SELLER_ENDPOINTS: {
630
+ readonly SETTINGS: "/api/store/whatsapp-settings";
631
+ readonly CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync";
632
+ readonly CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import";
633
+ };
629
634
  export declare const API_ENDPOINTS: {
630
635
  readonly CONFIG: {};
631
636
  readonly LOGS: {
@@ -1178,6 +1183,11 @@ export declare const API_ENDPOINTS: {
1178
1183
  readonly PRODUCTS: (sellerId: string) => string;
1179
1184
  readonly REVIEWS: (userId: string) => string;
1180
1185
  };
1186
+ readonly WHATSAPP_SELLER: {
1187
+ readonly SETTINGS: "/api/store/whatsapp-settings";
1188
+ readonly CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync";
1189
+ readonly CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import";
1190
+ };
1181
1191
  readonly SUPPORT: {
1182
1192
  readonly TICKETS: "/api/support/tickets";
1183
1193
  readonly TICKET_BY_ID: (id: string) => string;
@@ -1764,6 +1774,11 @@ export declare const API_ROUTES: {
1764
1774
  readonly PRODUCTS: (sellerId: string) => string;
1765
1775
  readonly REVIEWS: (userId: string) => string;
1766
1776
  };
1777
+ readonly WHATSAPP_SELLER: {
1778
+ readonly SETTINGS: "/api/store/whatsapp-settings";
1779
+ readonly CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync";
1780
+ readonly CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import";
1781
+ };
1767
1782
  readonly SUPPORT: {
1768
1783
  readonly TICKETS: "/api/support/tickets";
1769
1784
  readonly TICKET_BY_ID: (id: string) => string;
@@ -829,6 +829,13 @@ export const BEFORE_AFTER_ENDPOINTS = {
829
829
  LIST: "/api/before-after",
830
830
  };
831
831
  // ---------------------------------------------------------------------------
832
+ // WhatsApp Business (store-level settings + catalog sync)
833
+ // ---------------------------------------------------------------------------
834
+ export const WHATSAPP_SELLER_ENDPOINTS = {
835
+ SETTINGS: "/api/store/whatsapp-settings",
836
+ CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync",
837
+ CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import",
838
+ };
832
839
  // ---------------------------------------------------------------------------
833
840
  // Aggregate namespace export
834
841
  // ---------------------------------------------------------------------------
@@ -873,6 +880,7 @@ export const API_ENDPOINTS = {
873
880
  SUBLISTING_CATEGORIES: SUBLISTING_CATEGORY_ENDPOINTS,
874
881
  WISHLIST: WISHLIST_ENDPOINTS,
875
882
  PROFILE_STATS: PROFILE_STATS_ENDPOINTS,
883
+ WHATSAPP_SELLER: WHATSAPP_SELLER_ENDPOINTS,
876
884
  SUPPORT: SUPPORT_ENDPOINTS,
877
885
  SCAMMERS: SCAMMER_ENDPOINTS,
878
886
  REPORTS: REPORT_ENDPOINTS,
@@ -382,11 +382,15 @@ export declare const EVENT_FIELDS: {
382
382
  readonly ENDS_AT: "endsAt";
383
383
  readonly CREATED_BY: "createdBy";
384
384
  readonly STATS: "stats";
385
+ /** Promotional coupons shown on the event page — not prizes. */
386
+ readonly COUPON_IDS: "couponIds";
385
387
  readonly HAS_RAFFLE: "hasRaffle";
386
388
  readonly RAFFLE_TYPE: "raffleType";
387
389
  readonly RAFFLE_TOP_N: "raffleTopN";
388
390
  readonly RAFFLE_PRIZE: "rafflePrize";
391
+ /** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
389
392
  readonly RAFFLE_PRIZE_COUPON_ID: "rafflePrizeCouponId";
393
+ readonly RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds";
390
394
  readonly RAFFLE_GITHUB_FUNCTION_URL: "raffleGithubFunctionUrl";
391
395
  readonly RAFFLE_WINNER_USER_ID: "raffleWinnerUserId";
392
396
  readonly RAFFLE_WINNER_DISPLAY_NAME: "raffleWinnerDisplayName";
@@ -436,12 +436,16 @@ export const EVENT_FIELDS = {
436
436
  ENDS_AT: "endsAt",
437
437
  CREATED_BY: "createdBy",
438
438
  STATS: "stats",
439
+ /** Promotional coupons shown on the event page — not prizes. */
440
+ COUPON_IDS: "couponIds",
439
441
  // Raffle fields (SB9)
440
442
  HAS_RAFFLE: "hasRaffle",
441
443
  RAFFLE_TYPE: "raffleType",
442
444
  RAFFLE_TOP_N: "raffleTopN",
443
445
  RAFFLE_PRIZE: "rafflePrize",
446
+ /** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
444
447
  RAFFLE_PRIZE_COUPON_ID: "rafflePrizeCouponId",
448
+ RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds",
445
449
  RAFFLE_GITHUB_FUNCTION_URL: "raffleGithubFunctionUrl",
446
450
  RAFFLE_WINNER_USER_ID: "raffleWinnerUserId",
447
451
  RAFFLE_WINNER_DISPLAY_NAME: "raffleWinnerDisplayName",
@@ -12,7 +12,7 @@
12
12
  * **18 of the 27 real types** and was offered two that never appear.
13
13
  * · `AdminSiteSettingsView`'s per-channel allow-list, so an admin literally
14
14
  * **could not allow-list `offer_received` or `payment_review`** for email
15
- * — the toggle did not exist.
15
+ * or WhatsApp — the toggle did not exist.
16
16
  *
17
17
  * Everything here now derives from `NOTIFICATION_TYPE_VALUES`, so a new
18
18
  * notification type is one edit and cannot be missing from either surface.
@@ -12,7 +12,7 @@
12
12
  * **18 of the 27 real types** and was offered two that never appear.
13
13
  * · `AdminSiteSettingsView`'s per-channel allow-list, so an admin literally
14
14
  * **could not allow-list `offer_received` or `payment_review`** for email
15
- * — the toggle did not exist.
15
+ * or WhatsApp — the toggle did not exist.
16
16
  *
17
17
  * Everything here now derives from `NOTIFICATION_TYPE_VALUES`, so a new
18
18
  * notification type is one edit and cannot be missing from either surface.
@@ -12,6 +12,11 @@ export interface ResolvedKeys {
12
12
  /** Webhook signing secret. Optional in practice: without it `verifyWebhook()` fails closed and the buyer-present /verify path still works. */
13
13
  razorpayWebhookSecret: string;
14
14
  resendApiKey: string;
15
+ whatsappApiKey: string;
16
+ whatsappPhoneNumberId: string;
17
+ whatsappCloudApiToken: string;
18
+ /** Comma-separated digits-only numbers, e.g. "919876543210,918765432109" */
19
+ whatsappAdminNotifyNumbers: string;
15
20
  }
16
21
  /** Drops the in-process cache. Call this after admin saves new credentials. */
17
22
  export declare function invalidateIntegrationKeysCache(): void;
@@ -32,6 +32,10 @@ const EMPTY_KEYS = {
32
32
  razorpayKeySecret: "",
33
33
  razorpayWebhookSecret: "",
34
34
  resendApiKey: "",
35
+ whatsappApiKey: "",
36
+ whatsappPhoneNumberId: "",
37
+ whatsappCloudApiToken: "",
38
+ whatsappAdminNotifyNumbers: "",
35
39
  };
36
40
  let _cache = null;
37
41
  const CACHE_TTL_MS = 60000; // 1 minute
@@ -79,6 +83,12 @@ export async function resolveKeys() {
79
83
  phonepeWebhookUsername: db.phonepeWebhookUsername || process.env.PHONEPE_WEBHOOK_USERNAME || "",
80
84
  phonepeWebhookPassword: db.phonepeWebhookPassword || process.env.PHONEPE_WEBHOOK_PASSWORD || "",
81
85
  resendApiKey: db.resendApiKey || process.env.RESEND_API_KEY || "",
86
+ whatsappApiKey: db.whatsappApiKey || process.env.WHATSAPP_API_KEY || "",
87
+ whatsappPhoneNumberId: db.whatsappPhoneNumberId || process.env.WHATSAPP_PHONE_NUMBER_ID || "",
88
+ whatsappCloudApiToken: db.whatsappCloudApiToken || process.env.WHATSAPP_CLOUD_API_TOKEN || "",
89
+ whatsappAdminNotifyNumbers: db.whatsappAdminNotifyNumbers ||
90
+ process.env.WHATSAPP_ADMIN_NOTIFY_NUMBERS ||
91
+ "",
82
92
  };
83
93
  _cache = { value, expiresAt: Date.now() + CACHE_TTL_MS };
84
94
  return value;
@@ -66,6 +66,12 @@ export async function FeesView({} = {}) {
66
66
  who: t("paidByBuyer"),
67
67
  note: t("codHandlingFeeNote"),
68
68
  },
69
+ {
70
+ category: t("whatsappNotifyFeeTitle"),
71
+ rate: t("whatsappNotifyFeeRate"),
72
+ who: t("paidByBuyer"),
73
+ note: t("whatsappNotifyFeeNote"),
74
+ },
69
75
  {
70
76
  category: t("giftWrapFeeTitle"),
71
77
  rate: t("giftWrapFeeRate"),
@@ -25,7 +25,7 @@ const SECTIONS = [
25
25
  },
26
26
  {
27
27
  Icon: CheckCircle, title: "Order Confirmation",
28
- content: (_jsxs(_Fragment, { children: [_jsx(Text, { className: GC.textMuted, children: "After a successful payment you'll see an on-screen confirmation with your order ID. You'll also receive:" }), _jsxs(Ul, { className: `${GC.listDiscMuted} mt-3`, children: [_jsx(Li, { children: "An email confirmation to your registered address" }), _jsx(Li, { children: "An in-app notification you can read any time from the bell in the header" })] }), _jsxs(Text, { className: `${GC.textMuted} mt-3`, children: ["View all your orders at any time under ", _jsx(Span, { weight: "bold", children: "My Account \u2192 My Orders" }), "."] })] })),
28
+ content: (_jsxs(_Fragment, { children: [_jsx(Text, { className: GC.textMuted, children: "After a successful payment you'll see an on-screen confirmation with your order ID. You'll also receive:" }), _jsxs(Ul, { className: `${GC.listDiscMuted} mt-3`, children: [_jsx(Li, { children: "An email confirmation to your registered address" }), _jsx(Li, { children: "A WhatsApp message if you've added and verified your phone number" })] }), _jsxs(Text, { className: `${GC.textMuted} mt-3`, children: ["View all your orders at any time under ", _jsx(Span, { weight: "bold", children: "My Account \u2192 My Orders" }), "."] })] })),
29
29
  },
30
30
  {
31
31
  Icon: UserX, title: "Guest Limitations",
@@ -30,7 +30,7 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
30
30
  const { showToast } = useToast();
31
31
  const { data: settings } = useSiteSettings();
32
32
  const adminChannels = settings?.notificationChannels;
33
- const [channels, setChannels] = useState({ email: true, sms: true });
33
+ const [channels, setChannels] = useState({ email: true, whatsapp: true, sms: true });
34
34
  const [types, setTypes] = useState({
35
35
  orderUpdates: true, bids: true, promotions: true,
36
36
  system: true, reviews: true, messages: true, offers: true,
@@ -92,7 +92,8 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
92
92
  }
93
93
  // Which channels are admin-enabled (only show those toggles to the user)
94
94
  const emailAdminEnabled = adminChannels?.email?.enabled ?? false;
95
+ const whatsappAdminEnabled = adminChannels?.whatsapp?.enabled ?? false;
95
96
  const smsAdminEnabled = adminChannels?.sms?.enabled ?? false;
96
- const anyChannelEnabled = emailAdminEnabled || smsAdminEnabled;
97
- return (_jsxs(Stack, { gap: "lg", children: [_jsx(SectionCard, { title: "In-app notifications", children: _jsxs(Row, { align: "center", justify: "between", gap: "md", padding: "y-sm", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: "In-app notifications" }), _jsx(Text, { variant: "secondary", className: "mt-0.5", size: "xs", children: "Always on \u2014 displayed in the notification bell and inbox" })] }), _jsx(Row, { className: "h-5 w-9 bg-[var(--appkit-color-primary)] px-[0.125rem]", align: "center", justify: "end", rounded: "full", "aria-label": "Always on", children: _jsx(Span, { className: "h-4 w-4", rounded: "full", surface: "default", shadow: "default" }) })] }) }), anyChannelEnabled && (_jsxs(SectionCard, { title: "Notification channels", children: [emailAdminEnabled && (_jsx(ToggleRow, { label: "Email", description: "Receive notifications by email", checked: channels.email !== false, onChange: (v) => setChannel("email", v) })), smsAdminEnabled && (_jsx(ToggleRow, { label: "SMS", description: "Receive SMS text notifications", checked: channels.sms !== false, onChange: (v) => setChannel("sms", v) }))] })), _jsx(SectionCard, { title: "Notification types", children: TYPE_ROWS.map((row) => (_jsx(ToggleRow, { label: row.label, description: row.description, checked: types[row.key] !== false, onChange: (v) => setType(row.key, v) }, row.key))) }), _jsxs(Row, { justify: "end", gap: "sm", children: [saved && (_jsx(Text, { className: "text-[var(--appkit-color-success)] self-center", size: "sm", children: "Preferences saved \u2713" })), _jsx(Button, { onClick: handleSave, isLoading: saving, size: "sm", children: "Save preferences" })] })] }));
97
+ const anyChannelEnabled = emailAdminEnabled || whatsappAdminEnabled || smsAdminEnabled;
98
+ return (_jsxs(Stack, { gap: "lg", children: [_jsx(SectionCard, { title: "In-app notifications", children: _jsxs(Row, { align: "center", justify: "between", gap: "md", padding: "y-sm", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: "In-app notifications" }), _jsx(Text, { variant: "secondary", className: "mt-0.5", size: "xs", children: "Always on \u2014 displayed in the notification bell and inbox" })] }), _jsx(Row, { className: "h-5 w-9 bg-[var(--appkit-color-primary)] px-[0.125rem]", align: "center", justify: "end", rounded: "full", "aria-label": "Always on", children: _jsx(Span, { className: "h-4 w-4", rounded: "full", surface: "default", shadow: "default" }) })] }) }), anyChannelEnabled && (_jsxs(SectionCard, { title: "Notification channels", children: [emailAdminEnabled && (_jsx(ToggleRow, { label: "Email", description: "Receive notifications by email", checked: channels.email !== false, onChange: (v) => setChannel("email", v) })), whatsappAdminEnabled && (_jsx(ToggleRow, { label: "WhatsApp", description: "Receive notifications on WhatsApp", checked: channels.whatsapp !== false, onChange: (v) => setChannel("whatsapp", v) })), smsAdminEnabled && (_jsx(ToggleRow, { label: "SMS", description: "Receive SMS text notifications", checked: channels.sms !== false, onChange: (v) => setChannel("sms", v) }))] })), _jsx(SectionCard, { title: "Notification types", children: TYPE_ROWS.map((row) => (_jsx(ToggleRow, { label: row.label, description: row.description, checked: types[row.key] !== false, onChange: (v) => setType(row.key, v) }, row.key))) }), _jsxs(Row, { justify: "end", gap: "sm", children: [saved && (_jsx(Text, { className: "text-[var(--appkit-color-success)] self-center", size: "sm", children: "Preferences saved \u2713" })), _jsx(Button, { onClick: handleSave, isLoading: saving, size: "sm", children: "Save preferences" })] })] }));
98
99
  }
@@ -97,7 +97,7 @@ export function UserSidebar({ items, groups, mobileOpen = false, onCloseMobile,
97
97
  // ── Persistent sidebar variant (desktop left slide-over, mobile bottom-sheet) ──────
98
98
  if (variant === "sidebar") {
99
99
  const handleToggle = onToggle ?? close;
100
- return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[13rem] ${desktopOpen ? "translate-x-0" : rail.collapsedTranslateClass}`, children: [_jsxs(Stack, { border: "default", surface: "default", className: `flex-1 ${rail.borderClass} dark:border-[var(--appkit-color-border)] ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: "My Account" }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname }) }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle, edge: hand })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: "My Account", children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }) }) })] }));
100
+ return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[18rem] ${desktopOpen ? "translate-x-0" : rail.collapsedTranslateClass}`, children: [_jsxs(Stack, { border: "default", surface: "default", className: `flex-1 ${rail.borderClass} dark:border-[var(--appkit-color-border)] ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: "My Account" }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname }) }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle, edge: hand })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: "My Account", children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }) }) })] }));
101
101
  }
102
102
  // ── Overlay variant (default) — portal on desktop, bottom-sheet on mobile ─
103
103
  const content = (_jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }));
@@ -75,6 +75,8 @@ export interface UserAddress {
75
75
  export interface NotificationChannelPrefs {
76
76
  /** Disable all email notifications for this user (default: true = enabled). */
77
77
  email?: boolean;
78
+ /** Disable all WhatsApp notifications for this user. */
79
+ whatsapp?: boolean;
78
80
  /** Disable all SMS notifications for this user. */
79
81
  sms?: boolean;
80
82
  }
@@ -11,8 +11,21 @@ export declare function getUnreadNotificationCount(userId: string): Promise<numb
11
11
  export interface SendNotificationInput extends NotificationCreateInput {
12
12
  /** User's email — used by the email channel when enabled. */
13
13
  userEmail?: string;
14
+ /** User's phone with country code, digits only — used by WhatsApp channel. */
15
+ userPhone?: string;
14
16
  /** Pre-rendered email HTML — falls back to a plain-text <p> when absent. */
15
17
  emailHtml?: string;
18
+ /**
19
+ * The store this notification is about, for the order-lifecycle types in
20
+ * `ORDER_GATED_TYPES`. Its WhatsApp subscription decides whether the
21
+ * WhatsApp arm runs; absent, those types fall back to in-app + email.
22
+ *
23
+ * Resolved HERE rather than by each caller: six call sites would otherwise
24
+ * each do their own store read and each be a place the rule could drift
25
+ * (Root Cause #75). The read only happens when the type is gated AND
26
+ * WhatsApp is otherwise eligible, so the common path costs nothing.
27
+ */
28
+ storeId?: string;
16
29
  /**
17
30
  * Which portal the recipient reads this in — decides where `actionUrl`
18
31
  * points when the caller does not supply one.
@@ -48,15 +61,17 @@ export interface SendNotificationInput extends NotificationCreateInput {
48
61
  export interface SendNotificationResult {
49
62
  notification: NotificationDocument;
50
63
  email: "sent" | "skipped" | "failed";
64
+ /** WhatsApp dispatch is now async (enqueued as a `whatsappNotify` job) — "queued" replaces "sent" as the immediate outcome; the real delivery result lands on `notification.whatsappStatus`. */
65
+ whatsapp: "queued" | "skipped" | "failed";
51
66
  }
52
67
  /**
53
68
  * Create an in-app notification and fan out to enabled external channels.
54
69
  *
55
- * In-app is always written. Email is sent only when:
70
+ * In-app is always written. Email / WhatsApp are sent only when:
56
71
  * 1. The channel is enabled in siteSettings.notificationChannels
57
72
  * 2. Notification priority meets the channel's minPriority threshold
58
73
  * 3. The optional `types` filter includes this notification type (or is absent)
59
- * 4. The contact field (userEmail) is supplied by the caller
74
+ * 4. The contact field (userEmail / userPhone) is supplied by the caller
60
75
  * 5. The required credentials are set in siteSettings.credentials
61
76
  */
62
77
  export declare function sendNotification(input: SendNotificationInput): Promise<SendNotificationResult>;
@@ -2,13 +2,15 @@ import { normalizeError } from "../../../errors/normalize.js";
2
2
  import { notificationRepository } from "../repository/notification.repository.js";
3
3
  import { siteSettingsRepository } from "../repository/site-settings.repository.js";
4
4
  import { userRepository } from "../../auth/repository/user.repository.js";
5
+ import { storeRepository } from "../../stores/repository/store.repository.js";
6
+ import { safeRead } from "../../../errors/safe-read.js";
5
7
  import { createResendProvider } from "../../../providers/email-resend/provider.js";
6
8
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
9
+ import { enqueueJob } from "../../jobs/actions/enqueue-job.js";
7
10
  import { serverLogger } from "../../../monitoring/index.js";
8
11
  import { resolveNotificationActionUrl, TYPE_AUDIENCE, } from "../../../_internal/shared/features/notifications/action-url.js";
9
12
  import { isEmailEligible } from "../../../_internal/shared/features/notifications/email-eligibility.js";
10
13
  import { renderNotificationEmail } from "../../email/notification-templates.js";
11
- import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore.js";
12
14
  /**
13
15
  * A notification's `actionUrl` is a site-relative path — correct for the
14
16
  * in-app bell, useless in an email, where a bare `/user/orders/x` is not a
@@ -31,6 +33,85 @@ function absoluteActionUrl(path) {
31
33
  const base = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "");
32
34
  return base ? `${base}${path}` : path;
33
35
  }
36
+ import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore.js";
37
+ /**
38
+ * Notification types whose WhatsApp send is gated behind the SELLER's
39
+ * standing WhatsApp-notifications subscription — see `storeId` on
40
+ * `SendNotificationInput`.
41
+ *
42
+ * 🛑 This replaced a per-ORDER ₹10 addon the buyer paid at checkout
43
+ * (`OrderDocument.whatsappNotifyAddon`). The axis moved deliberately: the
44
+ * platform owns one WhatsApp number, so the cost is a standing monthly one
45
+ * borne by the seller who benefits from it, not a line item a buyer opts into
46
+ * per order. A buyer should not have to purchase their own delivery updates.
47
+ *
48
+ * Every other type (bid_won, offers, scam reports, support tickets, admin
49
+ * alerts, …) keeps the original two gates — admin channel enabled, user has
50
+ * not opted out — unchanged.
51
+ */
52
+ /**
53
+ * Does this store subscribe to WhatsApp order notifications?
54
+ *
55
+ * The subscription is a monthly add-on priced by the admin
56
+ * (`commissions.whatsappNotifyMonthlyFee`); this reads only whether it is
57
+ * currently active. Billing lives with the other recurring charges, not here.
58
+ *
59
+ * 🛑 Fails CLOSED. A store we cannot read, or one that has not subscribed,
60
+ * gets no WhatsApp send — the buyer still receives the in-app notification and
61
+ * the email, which are ungated. Failing open would message customers on behalf
62
+ * of a seller who is not paying for it, and bill the platform for the template.
63
+ */
64
+ async function storeWhatsAppSubscribed(storeId) {
65
+ if (!storeId)
66
+ return false;
67
+ const store = await safeRead(() => storeRepository.findBySlug(storeId), {
68
+ route: "sendNotification",
69
+ key: `whatsapp-subscription:${storeId}`,
70
+ fallback: null,
71
+ });
72
+ return store?.whatsappNotifySubscribed === true;
73
+ }
74
+ const ORDER_GATED_TYPES = new Set([
75
+ "order_placed",
76
+ "order_confirmed",
77
+ "order_shipped",
78
+ "order_delivered",
79
+ "order_cancelled",
80
+ "refund_initiated",
81
+ ]);
82
+ /**
83
+ * Notification type → the flat `SiteSettingsCredentials` field holding its
84
+ * approved Meta template name.
85
+ *
86
+ * 🛑 **`Partial` here is CORRECT and is not the `typeToPrefsKey` mistake.**
87
+ *
88
+ * The distinction is what a missing key MEANS. A missing `typeToPrefsKey`
89
+ * entry silently ignored a user's opt-out — a real setting that read as
90
+ * honoured and was not. A missing entry here means "no Meta-approved template
91
+ * exists for this type", which is a fact about the Meta account, not a bug:
92
+ * six of the 28 types have one, and the rest legitimately fall back to
93
+ * free-form (which Meta accepts inside the 24-hour customer-service window
94
+ * and rejects outside it — the runner already logs that rejection).
95
+ *
96
+ * Getting six approved templates is a Meta Business review process, not a
97
+ * code change, so listing all 28 here with empty values would be a
98
+ * `Record<…>` that lies about coverage.
99
+ *
100
+ * The six that ARE approved are the order lifecycle, which is also the set
101
+ * gated behind the buyer's ₹10 addon above — the two lists agree on purpose.
102
+ */
103
+ const WHATSAPP_TEMPLATE_FIELD = {
104
+ order_placed: "whatsappTemplateOrderPlaced",
105
+ order_confirmed: "whatsappTemplateOrderConfirmed",
106
+ order_shipped: "whatsappTemplateOrderShipped",
107
+ order_delivered: "whatsappTemplateOrderDelivered",
108
+ order_cancelled: "whatsappTemplateOrderCancelled",
109
+ refund_initiated: "whatsappTemplateRefundInitiated",
110
+ };
111
+ function resolveWhatsAppTemplateName(type, creds) {
112
+ const field = WHATSAPP_TEMPLATE_FIELD[type];
113
+ return (field ? creds[field] : undefined)?.trim() ?? "";
114
+ }
34
115
  export async function markNotificationRead(id) {
35
116
  await notificationRepository.markAsRead(id);
36
117
  }
@@ -53,15 +134,15 @@ export async function getUnreadNotificationCount(userId) {
53
134
  /**
54
135
  * Create an in-app notification and fan out to enabled external channels.
55
136
  *
56
- * In-app is always written. Email is sent only when:
137
+ * In-app is always written. Email / WhatsApp are sent only when:
57
138
  * 1. The channel is enabled in siteSettings.notificationChannels
58
139
  * 2. Notification priority meets the channel's minPriority threshold
59
140
  * 3. The optional `types` filter includes this notification type (or is absent)
60
- * 4. The contact field (userEmail) is supplied by the caller
141
+ * 4. The contact field (userEmail / userPhone) is supplied by the caller
61
142
  * 5. The required credentials are set in siteSettings.credentials
62
143
  */
63
144
  export async function sendNotification(input) {
64
- const { userEmail, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
145
+ const { userEmail, userPhone, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
65
146
  /*
66
147
  * Fill `actionUrl` when the caller did not.
67
148
  *
@@ -103,12 +184,12 @@ export async function sendNotification(input) {
103
184
  * suppresses the emailed/messaged COPY, not the notification.
104
185
  *
105
186
  * One map gates both channels deliberately. If a type is ever genuinely
106
- * worth a channel of its own, split the map then rather than
187
+ * WhatsApp-worthy but not email-worthy, split the map then rather than
107
188
  * loosening this early return.
108
189
  */
109
190
  if (!isEmailEligible(notifInput.type) &&
110
191
  eligibilityOverride !== "staff_requested") {
111
- return { notification, email: "skipped" };
192
+ return { notification, email: "skipped", whatsapp: "skipped" };
112
193
  }
113
194
  // Load channel config + credentials (one Firestore read).
114
195
  let settings;
@@ -118,7 +199,7 @@ export async function sendNotification(input) {
118
199
  catch (err) {
119
200
  void normalizeError(err);
120
201
  serverLogger.warn("sendNotification: could not load siteSettings — skipping external channels", { err });
121
- return { notification, email: "skipped" };
202
+ return { notification, email: "skipped", whatsapp: "skipped" };
122
203
  }
123
204
  const channels = settings.notificationChannels ?? DEFAULT_NOTIFICATION_CHANNELS;
124
205
  const creds = settings.credentials ?? {};
@@ -150,6 +231,7 @@ export async function sendNotification(input) {
150
231
  ? userRepository.revealUser(fetchedUserDoc)
151
232
  : null;
152
233
  const resolvedEmail = userEmail ?? fetchedUserPlain?.email ?? undefined;
234
+ const resolvedPhone = userPhone ?? fetchedUserPlain?.phoneNumber ?? undefined;
153
235
  // Map NotificationType to a NotificationTypePrefs key.
154
236
  /*
155
237
  * 🛑 COMPLETE and compile-checked: `Record<NotificationType, …>`, not
@@ -212,7 +294,7 @@ export async function sendNotification(input) {
212
294
  const typeKey = typeToPrefsKey[type];
213
295
  // If the user has explicitly disabled this notification type, skip all external channels.
214
296
  if (typeKey && userTypePrefs[typeKey] === false) {
215
- return { notification, email: "skipped" };
297
+ return { notification, email: "skipped", whatsapp: "skipped" };
216
298
  }
217
299
  // Email channel — admin enabled AND user hasn't opted out
218
300
  let emailStatus = "skipped";
@@ -259,10 +341,73 @@ export async function sendNotification(input) {
259
341
  }
260
342
  }
261
343
  }
344
+ // WhatsApp channel — admin enabled AND user hasn't opted out AND (for
345
+ // order-lifecycle types) the buyer paid the per-order WhatsApp addon.
346
+ let whatsappStatus = "skipped";
347
+ if (channels.whatsapp.enabled &&
348
+ userChannelPrefs.whatsapp !== false &&
349
+ resolvedPhone &&
350
+ meetsMinPriority(priority, channels.whatsapp.minPriority) &&
351
+ (!channels.whatsapp.types?.length || channels.whatsapp.types.includes(type)) &&
352
+ (!ORDER_GATED_TYPES.has(type) || (await storeWhatsAppSubscribed(input.storeId)))) {
353
+ /*
354
+ * ENV FIRST, site settings second.
355
+ *
356
+ * The platform owns one WhatsApp number, so its credentials are
357
+ * deployment configuration rather than admin-editable content — they
358
+ * belong beside the other secrets in the env, not in a Firestore document
359
+ * an admin screen can rewrite. Rotating a leaked Meta token should be a
360
+ * redeploy, not a form submission.
361
+ *
362
+ * 🛑 `WHATSAPP_PHONE_NUMBER_ID` is the Cloud API's numeric id, NOT the
363
+ * dialable number: sends go to `/{phone-number-id}/messages`, and a `+1…`
364
+ * string there is a 404 that looks like a routing bug.
365
+ *
366
+ * The site-settings fallback is kept so an install that configured these
367
+ * through the admin screen keeps working — this changes precedence, not
368
+ * behaviour, and nothing has to be migrated.
369
+ */
370
+ const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID?.trim() ||
371
+ (creds.whatsappPhoneNumberId?.trim() ?? "");
372
+ const accessToken = process.env.WHATSAPP_MESSAGES_TOKEN?.trim() ||
373
+ (creds.whatsappCloudApiToken?.trim() ?? "");
374
+ if (phoneNumberId && accessToken) {
375
+ try {
376
+ const { jobId } = await enqueueJob({
377
+ jobType: "whatsappNotify",
378
+ payload: {
379
+ toPhone: resolvedPhone,
380
+ title,
381
+ message,
382
+ type,
383
+ templateName: resolveWhatsAppTemplateName(type, creds),
384
+ templateLanguage: creds.whatsappTemplateLanguage ?? "en",
385
+ notificationId: notification.id,
386
+ phoneNumberId,
387
+ accessToken,
388
+ },
389
+ requestedBy: input.userId,
390
+ });
391
+ whatsappStatus = "queued";
392
+ await notificationRepository
393
+ .update(notification.id, { whatsappStatus: "queued", whatsappJobId: jobId })
394
+ .catch((err) => {
395
+ void normalizeError(err);
396
+ serverLogger.warn("sendNotification: failed to write whatsappJobId onto notification doc", { notificationId: notification.id });
397
+ });
398
+ }
399
+ catch (err) {
400
+ void normalizeError(err);
401
+ serverLogger.error("sendNotification: WhatsApp job enqueue failed", { userId: input.userId, err });
402
+ whatsappStatus = "failed";
403
+ }
404
+ }
405
+ }
262
406
  serverLogger.info("sendNotification: dispatched", {
263
407
  userId: input.userId,
264
408
  type,
265
409
  email: emailStatus,
410
+ whatsapp: whatsappStatus,
266
411
  });
267
- return { notification, email: emailStatus };
412
+ return { notification, email: emailStatus, whatsapp: whatsappStatus };
268
413
  }
@@ -10,6 +10,8 @@ import { ImageUpload, MediaImage, useMediaUpload } from "../../media/index.js";
10
10
  import { apiClient } from "../../../http/index.js";
11
11
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
12
12
  import { blogDraftSchema } from "../../blog/schemas/blog-form.js";
13
+ import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect.js";
14
+ import { ATTACHED_COUPON_MAX } from "../../promotions/constants/attached-coupons.js";
13
15
  import { SectionForm, useSectionFormNav } from "../../shell/index.js";
14
16
  const __P = {
15
17
  p4: "p-[var(--appkit-space-4)]",
@@ -39,6 +41,7 @@ const DEFAULT_DRAFT = {
39
41
  youtubeId: "",
40
42
  category: "news",
41
43
  tags: [],
44
+ couponIds: [],
42
45
  status: "published",
43
46
  isFeatured: true,
44
47
  authorName: "Admin",
@@ -103,6 +106,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
103
106
  youtubeId: p.youtubeId ?? "",
104
107
  category: p.category ?? "news",
105
108
  tags: Array.isArray(p.tags) ? p.tags : [],
109
+ couponIds: Array.isArray(p.couponIds) ? p.couponIds : [],
106
110
  status: p.status ?? "published",
107
111
  isFeatured: p.isFeatured ?? true,
108
112
  authorName: p.authorName ?? "Admin",
@@ -125,6 +129,10 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
125
129
  youtubeId: draft.youtubeId || undefined,
126
130
  category: draft.category,
127
131
  tags: draft.tags,
132
+ // Unconditional, `[]` included — see the matching note in the event
133
+ // editor: a key omitted when empty cannot clear the stored array,
134
+ // because the route merges.
135
+ couponIds: draft.couponIds,
128
136
  status: draft.status,
129
137
  isFeatured: draft.isFeatured,
130
138
  authorName: draft.authorName,
@@ -236,8 +244,8 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
236
244
  id: "publish",
237
245
  label: "Publish",
238
246
  quick: true,
239
- fields: ["status", "publishedAt", "authorName", "isFeatured"],
240
- render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
247
+ fields: ["status", "publishedAt", "authorName", "isFeatured", "couponIds"],
248
+ render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Coupons (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", multiple: true, value: values.couponIds, onChange: (ids) => onChange({ couponIds: ids }), placeholder: "Search coupons to feature in this post\u2026", allowCreate: false }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] mt-1", size: "xs", children: ["Shown as copyable code cards below the article. Up to ", ATTACHED_COUPON_MAX, ". Expired and deactivated coupons are hidden automatically, so an old post never advertises a dead code."] })] }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
241
249
  },
242
250
  ];
243
251
  const deleteModal = (_jsx(ConfirmDeleteModal, { isOpen: showDeleteConfirm, onConfirm: () => {
@@ -37,7 +37,7 @@ export interface AdminOrderEditorViewProps {
37
37
  paymentReviewOutcome?: string;
38
38
  /**
39
39
  * Paid add-ons + applied coupon for this order. Operational, not decorative:
40
- * `giftWrapAddon` tells the packer to wrap the parcel, and the
40
+ * `whatsappNotifyAddon` is who a status change should message, and the
41
41
  * coupon is what an admin needs when reconciling a discounted order.
42
42
  */
43
43
  addons?: OrderAddonBadgesOrder;
@@ -255,6 +255,8 @@ export function AdminOrdersView({ children, ...props }) {
255
255
  // Straight off the order document — the admin list route returns
256
256
  // raw docs, so these need no serializer entry (cf. Root Cause #38).
257
257
  addons: {
258
+ whatsappNotifyAddon: Boolean(item.whatsappNotifyAddon),
259
+ whatsappNotifyFee: Number(item.whatsappNotifyFee ?? 0) || undefined,
258
260
  giftWrapAddon: Boolean(item.giftWrapAddon),
259
261
  giftWrapFee: Number(item.giftWrapFee ?? 0) || undefined,
260
262
  giftWrapMessage: toStringValue(item.giftWrapMessage) || undefined,