@mohasinac/appkit 4.63.4 → 4.64.0

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 (232) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/categories/adapters.js +13 -0
  4. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  5. package/dist/_internal/server/features/checkout/actions.js +22 -5
  6. package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
  7. package/dist/_internal/server/features/events/like-actions.js +67 -0
  8. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  9. package/dist/_internal/server/features/events/likes.js +146 -0
  10. package/dist/_internal/server/features/orders/adapters.js +3 -1
  11. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  12. package/dist/_internal/server/features/seo/index.js +2 -0
  13. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  14. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  15. package/dist/_internal/server/features/seo/robots.js +20 -1
  16. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  17. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  18. package/dist/_internal/server/features/seo/static-params.js +228 -0
  19. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  20. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  21. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  22. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  23. package/dist/_internal/server/features/stores/adapters.js +88 -1
  24. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  25. package/dist/_internal/server/functions/scheduled.js +23 -1
  26. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  27. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  28. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  29. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  30. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  31. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  32. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  33. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  34. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  35. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  36. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  37. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  38. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  39. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  40. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  41. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  42. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  43. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  44. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  45. package/dist/_internal/server/notifications/channel-health.js +2 -2
  46. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  47. package/dist/_internal/server/notifications/send-guard.js +4 -2
  48. package/dist/_internal/shared/actions/action-registry.js +89 -0
  49. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  50. package/dist/_internal/shared/checkout/order-math.js +1 -1
  51. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  52. package/dist/_internal/shared/features/checkout/config.js +2 -0
  53. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  54. package/dist/_internal/shared/features/messaging/config.js +4 -1
  55. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  56. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  57. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  58. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  59. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  60. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  61. package/dist/_internal/shared/fees/calculator.js +7 -0
  62. package/dist/cli/index.js +2 -0
  63. package/dist/client.d.ts +2 -2
  64. package/dist/client.js +1 -1
  65. package/dist/constants/api-endpoints.d.ts +39 -0
  66. package/dist/constants/api-endpoints.js +16 -0
  67. package/dist/constants/notification-types.d.ts +1 -1
  68. package/dist/constants/notification-types.js +1 -1
  69. package/dist/core/integration-keys.d.ts +5 -0
  70. package/dist/core/integration-keys.js +10 -0
  71. package/dist/features/about/components/FeesView.js +6 -0
  72. package/dist/features/about/components/HelpPageView.js +9 -1
  73. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  74. package/dist/features/account/components/BecomeSellerView.js +2 -2
  75. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  76. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  77. package/dist/features/account/types/index.d.ts +2 -0
  78. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  79. package/dist/features/admin/actions/notification-actions.js +154 -9
  80. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  81. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  82. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  83. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  84. package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
  85. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  86. package/dist/features/admin/components/AdminStoresView.js +59 -2
  87. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  88. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  89. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  90. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  91. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  92. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  93. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  94. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  95. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  96. package/dist/features/admin/schemas/firestore.js +8 -3
  97. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  98. package/dist/features/auth/checkout-value-otp.js +1 -1
  99. package/dist/features/auth/permissions/constants.d.ts +2 -2
  100. package/dist/features/auth/permissions/constants.js +2 -1
  101. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  102. package/dist/features/blog/components/BlogListView.js +2 -1
  103. package/dist/features/blog/components/BlogPostView.js +2 -1
  104. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  105. package/dist/features/blog/constants/category-badge.js +40 -0
  106. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  107. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  108. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  109. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  110. package/dist/features/cart/repository/cart.repository.js +2 -1
  111. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  112. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  113. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  114. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  115. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  116. package/dist/features/categories/utils/load-category-options.js +50 -0
  117. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  118. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  119. package/dist/features/events/components/EventCard.js +4 -12
  120. package/dist/features/events/components/EventInsights.d.ts +27 -0
  121. package/dist/features/events/components/EventInsights.js +10 -0
  122. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  123. package/dist/features/events/components/EventLikeButton.js +59 -0
  124. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  125. package/dist/features/events/components/EventLikersRail.js +54 -0
  126. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  127. package/dist/features/events/components/EventTypeBadge.js +7 -0
  128. package/dist/features/events/components/EventsListPageView.js +2 -2
  129. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  130. package/dist/features/events/components/SpinWheel.js +128 -0
  131. package/dist/features/events/components/SpinWheelView.js +36 -15
  132. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  133. package/dist/features/events/constants/event-type-meta.js +109 -0
  134. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  135. package/dist/features/events/repository/event-likes.repository.js +151 -0
  136. package/dist/features/events/schemas/firestore.d.ts +111 -0
  137. package/dist/features/events/schemas/likes.d.ts +81 -0
  138. package/dist/features/events/schemas/likes.js +59 -0
  139. package/dist/features/events/types/index.d.ts +7 -0
  140. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  141. package/dist/features/events/utils/carousel-slide.js +174 -0
  142. package/dist/features/events/utils/event-charts.d.ts +65 -0
  143. package/dist/features/events/utils/event-charts.js +172 -0
  144. package/dist/features/faq/constants/help-guides.js +9 -0
  145. package/dist/features/faq/schemas/firestore.js +1 -0
  146. package/dist/features/faq/types/index.d.ts +19 -0
  147. package/dist/features/homepage/components/BrandsSection.js +25 -1
  148. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  149. package/dist/features/homepage/lib/section-renderer.js +1 -1
  150. package/dist/features/homepage/types/index.d.ts +7 -0
  151. package/dist/features/orders/actions/refund-actions.js +1 -0
  152. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  153. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  154. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  155. package/dist/features/orders/repository/orders.repository.js +2 -0
  156. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  157. package/dist/features/orders/types/index.d.ts +5 -2
  158. package/dist/features/search/action-index/settings-entries.js +9 -0
  159. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  160. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  161. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  162. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  163. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  164. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  165. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  166. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  167. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  168. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
  169. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  170. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  171. package/dist/features/stores/components/StoreGuideHubView.js +18 -5
  172. package/dist/features/stores/components/StoreHeader.js +2 -1
  173. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  174. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  175. package/dist/features/stores/repository/store.repository.js +52 -1
  176. package/dist/features/stores/schemas/firestore.d.ts +76 -7
  177. package/dist/features/stores/schemas/firestore.js +21 -6
  178. package/dist/features/stores/schemas/index.d.ts +88 -2
  179. package/dist/features/stores/schemas/index.js +14 -0
  180. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  181. package/dist/features/stores/schemas/store-create.js +86 -0
  182. package/dist/features/stores/types/index.d.ts +42 -0
  183. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  184. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  185. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  186. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  187. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  188. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  189. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  190. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  191. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  192. package/dist/features/whatsapp-bot/components/index.js +2 -0
  193. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  194. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  195. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  196. package/dist/features/whatsapp-bot/index.js +3 -0
  197. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  198. package/dist/features/whatsapp-bot/manifest.js +13 -0
  199. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  200. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  201. package/dist/features/whatsapp-bot/server.js +7 -0
  202. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  203. package/dist/features/whatsapp-bot/types/index.js +1 -0
  204. package/dist/index.d.ts +23 -6
  205. package/dist/index.js +38 -12
  206. package/dist/next/routing/route-map.d.ts +6 -1
  207. package/dist/next/routing/route-map.js +14 -0
  208. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  209. package/dist/providers/db-firebase/base.repository.js +1 -1
  210. package/dist/repositories/index.d.ts +1 -1
  211. package/dist/repositories/index.js +1 -1
  212. package/dist/schemas/registry.d.ts +115 -0
  213. package/dist/schemas/registry.js +2 -1
  214. package/dist/security/pii-encrypt.js +1 -1
  215. package/dist/server.d.ts +15 -2
  216. package/dist/server.js +32 -2
  217. package/dist/styles.css +36 -1
  218. package/dist/tailwind-utilities.css +1 -1
  219. package/dist/ui/components/HorizontalScroller.js +15 -2
  220. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  221. package/dist/ui/components/NavigationLoader.js +79 -30
  222. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  223. package/dist/ui/components/PageHeroHeading.js +15 -0
  224. package/dist/ui/components/Typography.d.ts +3 -3
  225. package/dist/ui/components/Typography.js +59 -31
  226. package/dist/ui/components/Typography.style.css +35 -0
  227. package/dist/ui/index.d.ts +2 -0
  228. package/dist/ui/index.js +1 -0
  229. package/package.json +1 -1
  230. package/scripts/seed-cli.mjs +4 -2
  231. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  232. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Chart specs for event pages — pure builders, no React, no recharts.
3
+ *
4
+ * Kept free of any import that reaches the DOM so a Server Component can call
5
+ * these and hand the result to `<Chart>`, whose own `"use client"` boundary is
6
+ * internal (`chart-spec.ts` is deliberately the same shape of module).
7
+ *
8
+ * ## 🛑 EVERY NUMBER HERE IS MEASURED, NOT ILLUSTRATIVE
9
+ *
10
+ * A chart asserts "we measured this". So each builder returns `null` rather
11
+ * than a plausible-looking shape when it has nothing real to plot — an absent
12
+ * chart says "not enough data yet", a flat or invented one says "we measured,
13
+ * and this is the result", which is a different and false claim.
14
+ *
15
+ * @tag domain:events
16
+ * @tag layer:utils
17
+ */
18
+ import { calculateDiscount } from "../../promotions/schemas/firestore.js";
19
+ /** A poll needs at least one real vote before a bar chart means anything. */
20
+ const MIN_VOTES = 1;
21
+ /**
22
+ * Poll results as bars.
23
+ *
24
+ * The data already existed and was rendered as the plain string
25
+ * "12 votes (34%)" — `getEventPollResults` computes both server-side and
26
+ * `EventLeaderboard` printed them in a flex row. This is the same numbers,
27
+ * shaped so the differences between options are visible at a glance.
28
+ */
29
+ export function buildPollChart(
30
+ // Structural, not the full `PollConfig`: this reads `options` and nothing
31
+ // else, and the page carries a narrower local declaration of its own.
32
+ pollConfig, results) {
33
+ if (!pollConfig?.options?.length || !results?.length)
34
+ return null;
35
+ const byId = new Map(results.map((r) => [r.optionId, r.count]));
36
+ const total = results.reduce((sum, r) => sum + r.count, 0);
37
+ if (total < MIN_VOTES)
38
+ return null;
39
+ return {
40
+ kind: "bar",
41
+ title: "Votes so far",
42
+ categories: pollConfig.options.map((o) => o.label),
43
+ series: [{ label: "Votes", values: pollConfig.options.map((o) => byId.get(o.id) ?? 0) }],
44
+ unit: "votes",
45
+ caption: `${total} vote${total === 1 ? "" : "s"} counted.`,
46
+ };
47
+ }
48
+ /**
49
+ * The basket sizes the savings chart compares across.
50
+ *
51
+ * 🛑 THE RANGE IS CHOSEN TO SHOW THE CROSSOVER, which is the only thing this
52
+ * chart can usefully tell someone. At ₹2,000 the two 10% codes TIE at ₹200 and
53
+ * their caps are irrelevant; an uncapped 5% only overtakes a ₹500-capped 10%
54
+ * once the basket passes ₹10,000. A single-basket bar chart would show three
55
+ * bars, two of them identical, and teach nothing.
56
+ */
57
+ const BASKET_SIZES = [1000, 2000, 5000, 12000];
58
+ const INR = new Intl.NumberFormat("en-IN", { maximumFractionDigits: 0 });
59
+ /**
60
+ * What each cashback coupon actually saves, across basket sizes.
61
+ *
62
+ * 🛑 COMPUTED WITH THE REAL `calculateDiscount`, never a re-derived formula.
63
+ * That function is where `maxDiscount`, `minPurchase` and the tier table are
64
+ * honoured; computing the percentage inline here would silently ignore all three and
65
+ * overstate every capped code. Root Cause #75 is a money rule hand-copied into
66
+ * six places, where the copies never learned about a branch added later.
67
+ *
68
+ * 🛑 `free_shipping` AND `buy_x_get_y` ARE EXCLUDED, not plotted at zero.
69
+ * `calculateDiscount` returns 0 for free shipping BY DESIGN — the waiver is a
70
+ * shipping line, not a subtotal discount — and a BOGO's value depends on the
71
+ * basket's composition, not its total (₹500 on 4x₹500, ₹0 on 2x₹1,000). A bar
72
+ * at zero for either would read as "this code saves you nothing", which is a
73
+ * lie by omission. They belong in the caption, and that is where they go.
74
+ */
75
+ /**
76
+ * 🛑 TAKES THE PUBLIC PROJECTION, NOT THE DOCUMENT — and still calls the
77
+ * real `calculateDiscount`.
78
+ *
79
+ * The event page only ever holds `AttachedCoupon`, the allow-list
80
+ * projection (Root Cause #70 — the full document carries fields no public
81
+ * surface should see). That projection carries exactly the four facts the
82
+ * calculator needs, under different names: `type`, `discountValue`,
83
+ * `maxDiscount`, `minPurchase`.
84
+ *
85
+ * So this MAPS NAMES and delegates. The tempting shortcut — computing
86
+ * the percentage inline right here, because the projection makes it
87
+ * easy — is precisely Root Cause #75: a money rule re-derived at a call
88
+ * site, which then never learns about the cap, the minimum or the tier
89
+ * table the real function honours.
90
+ */
91
+ function toCalculable(c) {
92
+ return {
93
+ type: c.type,
94
+ discount: {
95
+ value: c.discountValue,
96
+ maxDiscount: c.maxDiscount,
97
+ minPurchase: c.minPurchase,
98
+ },
99
+ };
100
+ }
101
+ export function buildCouponSavingsChart(coupons) {
102
+ const plottable = coupons.filter((c) => c.type === "percentage" || c.type === "fixed");
103
+ if (plottable.length === 0)
104
+ return null;
105
+ const excluded = coupons
106
+ .filter((c) => !plottable.includes(c))
107
+ .map((c) => c.code);
108
+ const series = plottable.map((coupon) => ({
109
+ label: coupon.code,
110
+ values: BASKET_SIZES.map((basket) => Math.round(calculateDiscount(toCalculable(coupon), basket))),
111
+ }));
112
+ // Every code saving nothing at every size means the minimums are all above
113
+ // our largest basket — a chart of four empty groups.
114
+ if (series.every((s) => s.values.every((v) => v === 0)))
115
+ return null;
116
+ const captionParts = ["One code per order — this is a choose-one comparison."];
117
+ if (excluded.length) {
118
+ captionParts.push(`${excluded.join(" and ")} ${excluded.length === 1 ? "is" : "are"} not shown: ` +
119
+ `their value depends on delivery or on what is in the basket, not on the total.`);
120
+ }
121
+ return {
122
+ kind: "groupedBar",
123
+ title: "What each code saves",
124
+ categories: BASKET_SIZES.map((b) => `₹${INR.format(b)}`),
125
+ series,
126
+ unit: "₹",
127
+ caption: captionParts.join(" "),
128
+ };
129
+ }
130
+ /**
131
+ * Likes over time. Data comes from `getLikeTrend`, which returns `null` below
132
+ * `LIKE_TREND_MIN_POINTS` so a two-like event plots nothing rather than a flat
133
+ * line with axes.
134
+ */
135
+ export function buildLikeTrendChart(trend) {
136
+ if (!trend)
137
+ return null;
138
+ return {
139
+ kind: "area",
140
+ title: "Interest over the last fortnight",
141
+ categories: trend.categories,
142
+ series: [{ label: "Likes", values: trend.values }],
143
+ unit: "likes",
144
+ caption: "Likes received per day.",
145
+ };
146
+ }
147
+ /**
148
+ * Time elapsed in the sale window, and redemptions against a real cap.
149
+ *
150
+ * 🛑 `claimedPercent` IS null UNLESS A REAL CAP EXISTS. `usage.totalLimit` is
151
+ * optional, and an uncapped coupon has no denominator — rendering a progress
152
+ * bar against an invented 100 would show a bar filling toward a limit nobody
153
+ * set. Absent is the honest state and the caller renders only the countdown.
154
+ */
155
+ export function buildSaleProgress(startsAt, endsAt, coupon, now = new Date()) {
156
+ if (!startsAt || !endsAt)
157
+ return null;
158
+ const start = startsAt.getTime();
159
+ const end = endsAt.getTime();
160
+ if (!(end > start))
161
+ return null;
162
+ const elapsed = ((now.getTime() - start) / (end - start)) * 100;
163
+ const limit = coupon?.usage?.totalLimit;
164
+ const used = coupon?.usage?.currentUsage ?? 0;
165
+ const hasCap = typeof limit === "number" && limit > 0;
166
+ return {
167
+ elapsedPercent: Math.min(100, Math.max(0, Math.round(elapsed))),
168
+ endsAt: now.getTime() < end ? endsAt : null,
169
+ claimedPercent: hasCap ? Math.min(100, Math.round((used / limit) * 100)) : null,
170
+ claimedLabel: hasCap ? `${INR.format(used)} of ${INR.format(limit)} claimed` : null,
171
+ };
172
+ }
@@ -52,6 +52,15 @@ export const FAQ_CATEGORY_HELP_GUIDE = {
52
52
  // live.
53
53
  scam_awareness: { path: "/help/account", label: "Account & Safety guide" },
54
54
  fees_and_taxes: { path: "/help/shopping", label: "Shopping & Checkout guide" },
55
+ /*
56
+ * The BUYER-facing badge page, not `/seller-guide`.
57
+ *
58
+ * This map sits on the public FAQ page, so its audience is a visitor rather
59
+ * than a seller — and "what does Trusted Seller mean" is the question they
60
+ * are most likely to have arrived with. A seller reading these FAQs still
61
+ * gets there: the badge page links on to the seller guide.
62
+ */
63
+ selling: { path: "/help/seller-badges", label: "What seller badges mean" },
55
64
  technical_support: null,
56
65
  general: null,
57
66
  };
@@ -31,6 +31,7 @@ export const FAQ_CATEGORY_LABELS = {
31
31
  general: "General Questions",
32
32
  scam_awareness: "Scam Awareness",
33
33
  fees_and_taxes: "Fees & Taxes",
34
+ selling: "Selling & Seller Badges",
34
35
  };
35
36
  export const DEFAULT_FAQ_DATA = {
36
37
  showOnHomepage: false,
@@ -1,5 +1,24 @@
1
1
  export type FAQAnswerFormat = "plain" | "markdown" | "html";
2
2
  export type FAQCategory = "orders_payment" | "shipping_delivery" | "returns_refunds" | "product_information" | "account_security" | "technical_support" | "general" | "scam_awareness"
3
+ /**
4
+ * Questions asked by SELLERS, and the buyer-facing questions about them —
5
+ * how to open a store, what the badges mean, how standing is earned and
6
+ * lost.
7
+ *
8
+ * Kept out of `general` for the reason `fees_and_taxes` was: these have a
9
+ * distinct audience. A buyer browsing FAQs is not looking for "how do I
10
+ * list an item", and a seller looking for it should not have to read past
11
+ * twenty buyer questions to find it.
12
+ *
13
+ * 🛑 Adding a member here is FIVE coordinated edits, three of which are
14
+ * compile-checked and two of which are not: this union,
15
+ * `FAQ_CATEGORY_LABELS`, the `Record<FAQCategory, …>` in
16
+ * `constants/help-guides.ts` — and then the HARDCODED category arrays in
17
+ * `src/app/[locale]/faqs/page.tsx` and `faqs/[category]/page.tsx`, which
18
+ * nothing type-checks. A comment in those files records `fees_and_taxes`
19
+ * needing exactly this.
20
+ */
21
+ | "selling"
3
22
  /**
4
23
  * D6b. Money questions, kept out of `orders_payment` on purpose: that
5
24
  * category is about an ORDER (where is it, how do I pay, can I cancel),
@@ -29,7 +29,31 @@ function BrandLogo({ brand }) {
29
29
  const iconSrc = brand.display?.icon;
30
30
  const coverImage = brand.display?.coverImage;
31
31
  const initial = brand.name[0]?.toUpperCase() ?? "?";
32
- return (_jsxs(Link, { href: ROUTES.PUBLIC.CATEGORY_DETAIL(brand.slug), className: "flex h-32 w-36 flex-col items-center justify-center gap-[var(--appkit-space-2)] rounded-xl border p-[var(--appkit-space-3)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] md:h-40", children: [iconSrc || coverImage ? (_jsx(Image, { src: resolveMediaUrl(iconSrc ?? coverImage), alt: brand.name, width: 64, height: 64, className: "h-16 w-16 rounded object-contain" })) : (_jsx(Row, { textWeight: "bold", textSize: "sm", className: "h-16 w-16 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: initial })), _jsx(Text, { className: "w-full truncate", color: "muted", size: "xs", weight: "medium", align: "start", children: brand.name })] }));
32
+ return (_jsxs(Link
33
+ /*
34
+ * 🛑 BRAND_DETAIL, not CATEGORY_DETAIL.
35
+ *
36
+ * A brand IS a `categoryType: "brand"` row, so `/categories/brand-hasbro`
37
+ * resolves just as `/brands/brand-hasbro` does — and both are indexable.
38
+ * This component was the one surface linking brands by the category path
39
+ * while every other surface used `/brands/…`, so the homepage shipped two
40
+ * different URLs for the same entity and split its signals between them.
41
+ * `/brands/{slug}` is the one the sitemap advertises and the one the
42
+ * brand page declares canonical.
43
+ */
44
+ , {
45
+ /*
46
+ * 🛑 BRAND_DETAIL, not CATEGORY_DETAIL.
47
+ *
48
+ * A brand IS a `categoryType: "brand"` row, so `/categories/brand-hasbro`
49
+ * resolves just as `/brands/brand-hasbro` does — and both are indexable.
50
+ * This component was the one surface linking brands by the category path
51
+ * while every other surface used `/brands/…`, so the homepage shipped two
52
+ * different URLs for the same entity and split its signals between them.
53
+ * `/brands/{slug}` is the one the sitemap advertises and the one the
54
+ * brand page declares canonical.
55
+ */
56
+ href: ROUTES.PUBLIC.BRAND_DETAIL(brand.slug), className: "flex h-32 w-36 flex-col items-center justify-center gap-[var(--appkit-space-2)] rounded-xl border p-[var(--appkit-space-3)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] md:h-40", children: [iconSrc || coverImage ? (_jsx(Image, { src: resolveMediaUrl(iconSrc ?? coverImage), alt: brand.name, width: 64, height: 64, className: "h-16 w-16 rounded object-contain" })) : (_jsx(Row, { textWeight: "bold", textSize: "sm", className: "h-16 w-16 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: initial })), _jsx(Text, { className: "w-full truncate", color: "muted", size: "xs", weight: "medium", align: "start", children: brand.name })] }));
33
57
  }
34
58
  export function BrandsSection({ title = "Top Brands", subtitle, limit = 12, viewMoreHref, viewMoreLabel = "All brands →", className = "", initialItems, cta, filters, autoScroll = true, scrollInterval = 4000, loop = true, }) {
35
59
  const themed = { textPrimary: THEMED_TEXT_PRIMARY };
@@ -72,7 +72,16 @@ function getSlideHeightClass(height) {
72
72
  /** Renders the slide background — image, video, solid colour, or gradient. */
73
73
  function SlideBackground({ bg, legacy, mobileLegacy, isMobile, priority, }) {
74
74
  const effectiveBg = bg ?? (legacy ? { type: legacy.type, url: legacy.url } : undefined);
75
- const mobileUrl = effectiveBg?.mobileUrl ?? (isMobile && mobileLegacy ? mobileLegacy.url : undefined);
75
+ /*
76
+ * 🛑 Both halves are gated on `isMobile`. `effectiveBg.mobileUrl` was read
77
+ * UNCONDITIONALLY, so any slide carrying a mobile crop served that
78
+ * phone-sized image to desktop too — a narrow picture stretched across a
79
+ * 1920px hero, and the desktop asset never loaded at all. The legacy half
80
+ * was already gated correctly, which is what made the asymmetry easy to miss.
81
+ */
82
+ const mobileUrl = isMobile
83
+ ? (effectiveBg?.mobileUrl ?? mobileLegacy?.url)
84
+ : undefined;
76
85
  const src = mobileUrl ?? effectiveBg?.url ?? "";
77
86
  if (!effectiveBg) {
78
87
  return _jsx(Div, { className: `${POSITION_FILL} bg-neutral-900` });
@@ -111,17 +120,25 @@ function CardBackground({ bg }) {
111
120
  return null;
112
121
  }
113
122
  function CarouselCardRenderer({ card, isMobile, push, }) {
114
- const mobileZone = card.mobileZone;
115
- const gridPos = isMobile && mobileZone
116
- ? { gridRow: mobileZone <= 3 ? 1 : 2, gridColumn: mobileZone <= 3 ? mobileZone : mobileZone - 3 }
117
- : resolveZoneToGrid(card.zone);
123
+ /*
124
+ * 🛑 On mobile the card takes NO explicit grid placement at all — the parent
125
+ * is a one-column auto-flow grid, so source order is the layout.
126
+ *
127
+ * The old branch mapped `mobileZone` to a `gridColumn`, which against a
128
+ * single declared column created an implicit column 2 and stacked every
129
+ * card into one cell. `mobileZone` was also typed `2 | 5` only, so it could
130
+ * not express a six-card slide even in principle. Zones are a desktop
131
+ * concept; a phone has one column and nothing to place into.
132
+ */
133
+ const gridPos = isMobile ? null : resolveZoneToGrid(card.zone);
118
134
  const hoverClass = getHoverClass(card.hover?.effect);
119
135
  const textAlign = card.content?.textAlign ?? "left";
120
136
  // audit-content-alignment-ok: admin-configurable per-card textAlign field, defaults to "left" — not a hardcoded center
121
137
  const textAlignClass = textAlign === "center" ? "text-center" : textAlign === "right" ? "text-right" : "text-left";
122
138
  return (_jsxs(Div, { className: `relative overflow-hidden transition-all duration-300 ${hoverClass}`, rounded: "lg", shadow: "lg", style: {
123
- gridRow: String(gridPos.gridRow),
124
- gridColumn: String(gridPos.gridColumn),
139
+ ...(gridPos
140
+ ? { gridRow: String(gridPos.gridRow), gridColumn: String(gridPos.gridColumn) }
141
+ : {}),
125
142
  minHeight: isMobile ? 100 : 140,
126
143
  }, children: [_jsx(CardBackground, { bg: card.background }), !card.isButtonOnly && (_jsxs(_Fragment, { children: [_jsx(Scrim, { direction: "bottom-up", intensity: "strong", className: POSITION_FILL }), _jsxs(Stack, { justify: "end", className: `${POSITION_FILL} md:p-[1.5rem] ${textAlignClass}`, padding: "xs", children: [card.content?.eyebrow && (_jsx(Text, { color: "inverse", shadow: "sm", className: "text-[10px] opacity-70 mb-0.5 tracking-wider", mdSize: "xs", transform: "uppercase", children: card.content.eyebrow })), card.content?.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "hidden md:block opacity-90 mb-0.5 md:mb-2", mdSize: "sm", size: "xs", children: card.content.subtitle })), card.content?.title && (
127
144
  // level={3}: a card sits INSIDE a slide, so it is subordinate to
@@ -202,15 +219,35 @@ export function HeroCarousel({ initialSlides, push, height, defaultAutoplayDelay
202
219
  onMouseEnter: pauseOnHover ? () => setIsPaused(true) : undefined, onMouseLeave: pauseOnHover ? () => setIsPaused(false) : undefined, onFocus: () => setIsPaused(true), onBlur: () => setIsPaused(false), tabIndex: 0, children: [_jsx(Div, { "aria-live": "polite", "aria-atomic": "true", className: "sr-only", children: `Slide ${currentSlide + 1} of ${slides.length}` }), _jsx(Div, { className: POSITION_FILL, children: _jsx(Div, { layout: "flex", ref: slidesRef, onScroll: handleSlidesScroll, className: "h-full overflow-x-auto scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", style: { gap: 0 }, children: slides.map((slide, slideIndex) => {
203
220
  const slideHeightClass = getSlideHeightClass(slide.settings?.height);
204
221
  const hasCards = slide.cards && slide.cards.length > 0;
205
- return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: "absolute bottom-2 left-2 z-10", children: _jsx(OwnerQuickActions, { resource: "CAROUSEL", placement: "card", recordLabel: slide.title ?? `Slide ${slideIndex + 1}`, editHref: String(ROUTES.ADMIN.CAROUSEL_EDIT(slide.id)) }) }), _jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
222
+ return (_jsxs(Div, { className: `snap-start flex-none w-full relative self-stretch bg-neutral-900 ${slideHeightClass}`, children: [_jsx(Div, { className: "absolute bottom-2 left-2 z-10", children: _jsx(OwnerQuickActions, { resource: "CAROUSEL", placement: "card",
223
+ /* Sits on the slide photograph — without this it renders
224
+ * the light `surface-elevated` chip, a pale rounded box
225
+ * floating on the artwork. `onMedia` selects the dark
226
+ * translucent backdrop that variant exists for. */
227
+ onMedia: true, recordLabel: slide.title ?? `Slide ${slideIndex + 1}`, editHref: String(ROUTES.ADMIN.CAROUSEL_EDIT(slide.id)) }) }), _jsx(Div, { className: POSITION_FILL, children: _jsx(SlideBackground, { bg: slide.background, legacy: slide.media, mobileLegacy: slide.mobileMedia, isMobile: isMobile, priority: slideIndex === 0 }) }), slide.overlay && slide.overlay.enabled !== false && (_jsxs(Div, { layout: "flex-col", align: "start", justify: "center", className: `${POSITION_FILL} text-left md:px-[4rem] lg:px-[8rem]`, padding: "x-lg", children: [slide.overlay.subtitle && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-1 opacity-80 mb-1 md:mb-2 tracking-widest", mdSize: "sm", size: "xs", transform: "uppercase", children: slide.overlay.subtitle })), slide.overlay.title && (
206
228
  // level={2}, NOT 1. The welcome section above this
207
229
  // carousel owns the page's single <h1>; a carousel that
208
230
  // emits one <h1> per slide gives the document as many
209
231
  // h1s as it has slides. `size` is independent of `level`,
210
232
  // so this changes the outline and not one pixel.
211
- _jsx(Heading, { color: "inverse", level: 2, className: "stagger-2 font-display ! mb-2 md:mb-4 break-words", size: "4xl", shadow: "2xl", mdSize: "6xl", lgSize: "8xl", children: slide.overlay.title })), slide.overlay.description && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-3 opacity-90 mb-4 md:mb-8 max-w-2xl", mdSize: "lg", lgSize: "xl", size: "sm", children: slide.overlay.description })), slide.overlay.button && (_jsx(Div, { className: "stagger-4", children: _jsx(Button, { variant: slide.overlay.button.variant, size: "sm", onClick: makeButtonClickHandler(slide.overlay.button.link, slide.overlay.button.openInNewTab, push), children: slide.overlay.button.text }) }))] })), hasCards && (_jsx(Div, { className: `${POSITION_FILL} grid gap-[var(--appkit-space-2)] md:gap-[var(--appkit-space-4)] md:p-[var(--appkit-space-8)]`, padding: "md", style: {
212
- gridTemplateRows: "repeat(2, 1fr)",
233
+ _jsx(Heading, { color: "inverse", level: 2,
234
+ /*
235
+ * 🛑 `max-w-[16ch]` is load-bearing, not taste. The box
236
+ * is `absolute inset-0`, so with no cap the title runs
237
+ * the full slide width minus padding and an 8xl
238
+ * headline wraps into a wall. A character-based cap
239
+ * tracks the font size instead of guessing at pixels.
240
+ *
241
+ * `text-balance` evens the lines so a two-line title
242
+ * does not leave one word stranded on line two.
243
+ *
244
+ * (A stray bare `!` token sat in this className and
245
+ * generated nothing — removed.)
246
+ */
247
+ className: "stagger-2 font-display mb-2 md:mb-4 break-words text-balance max-w-[16ch] md:max-w-[18ch] lg:max-w-[20ch]", size: "4xl", shadow: "2xl", mdSize: "6xl", lgSize: "8xl", children: slide.overlay.title })), slide.overlay.description && (_jsx(Text, { color: "inverse", shadow: "sm", className: "stagger-3 opacity-90 mb-4 md:mb-8 max-w-2xl", mdSize: "lg", lgSize: "xl", size: "sm", children: slide.overlay.description })), slide.overlay.button && (_jsx(Div, { className: "stagger-4", children: _jsx(Button, { variant: slide.overlay.button.variant, size: "sm", onClick: makeButtonClickHandler(slide.overlay.button.link, slide.overlay.button.openInNewTab, push), children: slide.overlay.button.text }) }))] })), hasCards && (_jsx(Div, { className: `${POSITION_FILL} grid gap-[var(--appkit-space-2)] md:gap-[var(--appkit-space-4)] md:p-[var(--appkit-space-8)] overflow-y-auto`, padding: "md", style: {
248
+ gridTemplateRows: isMobile ? "auto" : "repeat(2, 1fr)",
213
249
  gridTemplateColumns: isMobile ? "1fr" : "repeat(3, 1fr)",
250
+ gridAutoFlow: "row",
214
251
  alignContent: "center",
215
252
  alignItems: "stretch",
216
253
  }, children: slide.cards.slice(0, 6).map((card) => (_jsx(CarouselCardRenderer, { card: card, isMobile: isMobile, push: push }, card.id))) }))] }, slide.id));
@@ -259,7 +259,7 @@ const SECTION_REGISTRY = {
259
259
  return (_jsx(WhatsAppCommunitySection, { title: cleanTitle(cfg?.title) || SECTION_TITLE.whatsappCommunity, descriptionHtml: cfg?.description || SECTION_COPY.whatsappDescription,
260
260
  // No `|| 5000` fallback: inventing a member count the site cannot
261
261
  // verify is a fabricated social-proof number.
262
- memberCount: cfg?.memberCount, groupLink: cfg?.groupLink || undefined, benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
262
+ memberCount: cfg?.memberCount, groupLink: cfg?.groupLink || "https://chat.whatsapp.com/", benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
263
263
  },
264
264
  },
265
265
  faq: {
@@ -125,6 +125,13 @@ export interface CarouselSlide {
125
125
  };
126
126
  cards: CarouselSlideCard[];
127
127
  overlay?: {
128
+ /**
129
+ * Absent means SHOWN — most stored overlays predate this flag. Only an
130
+ * explicit `false` hides it. The seed and `push-editorial-content.mjs`
131
+ * have always written `enabled: true`; the renderer simply never read it,
132
+ * so an admin switching it off changed nothing.
133
+ */
134
+ enabled?: boolean;
128
135
  title?: string;
129
136
  subtitle?: string;
130
137
  description?: string;
@@ -79,6 +79,7 @@ export async function issuePartialRefund(adminUid, orderId, deductFees, refundNo
79
79
  : `Your full refund of ₹${grossRefund} is being processed.`,
80
80
  relatedId: orderId,
81
81
  relatedType: "order",
82
+ storeId: order.storeId,
82
83
  });
83
84
  serverLogger.info("Admin partial refund initiated", {
84
85
  adminUid,
@@ -2,7 +2,7 @@ import React from "react";
2
2
  /**
3
3
  * Icon badges for an order's paid add-ons and applied coupon.
4
4
  *
5
- * These are fulfilment instructions, not decoration: gift wrap tells the
5
+ * These are fulfilment instructions, not decoration: WhatsApp tells the
6
6
  * status-change notifier to message this buyer, gift wrap tells the packer to
7
7
  * wrap the parcel and enclose the message, and either protection changes how a
8
8
  * claim is handled. They are recorded on the order (and filterable in the
@@ -14,6 +14,8 @@ import React from "react";
14
14
  * at once instead of being wired per screen.
15
15
  */
16
16
  export interface OrderAddonBadgesOrder {
17
+ whatsappNotifyAddon?: boolean;
18
+ whatsappNotifyFee?: number;
17
19
  giftWrapAddon?: boolean;
18
20
  giftWrapFee?: number;
19
21
  giftWrapMessage?: string;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Gift, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
3
+ import { Gift, MessageCircle, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
4
4
  import { Row, Span, Stack, Text } from "../../../ui/index.js";
5
5
  import { formatCurrency } from "../../../utils/number.formatter.js";
6
6
  const CHIP_BASE = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[length:var(--appkit-text-xs)] font-medium";
@@ -15,14 +15,15 @@ function Chip({ icon, label, tone, title, }) {
15
15
  export function OrderAddonBadges({ order, variant = "compact", showCoupon = true, currency = "INR", className = "", }) {
16
16
  const money = (v) => (typeof v === "number" && v > 0 ? formatCurrency(v, currency) : undefined);
17
17
  const hasCoupon = showCoupon && !!order.couponCode;
18
- const hasAny = order.giftWrapAddon ||
18
+ const hasAny = order.whatsappNotifyAddon ||
19
+ order.giftWrapAddon ||
19
20
  order.shipmentProtectionAddon ||
20
21
  order.saleProtectionAddon ||
21
22
  hasCoupon;
22
23
  if (!hasAny)
23
24
  return null;
24
25
  const isDetail = variant === "detail";
25
- return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
26
+ return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.whatsappNotifyAddon && (_jsx(Chip, { icon: _jsx(MessageCircle, { size: 12 }), label: isDetail && money(order.whatsappNotifyFee) ? `WhatsApp updates · ${money(order.whatsappNotifyFee)}` : "WhatsApp updates", tone: "success", title: "Buyer opted into WhatsApp order-status updates \u2014 status changes on this order should notify them on WhatsApp." })), order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
26
27
  ? `${order.couponCode} · −${money(order.couponDiscount)}`
27
28
  : order.couponCode, tone: "success", title: `Coupon ${order.couponCode} applied to this order.` }))] }), isDetail && order.giftWrapAddon && order.giftWrapMessage && (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { size: "xs", color: "muted", weight: "semibold", transform: "uppercase", className: "tracking-wide", children: "Gift message" }), _jsx(Text, { size: "sm", color: "primary", className: "min-w-0 whitespace-pre-wrap break-words", children: order.giftWrapMessage })] }))] }));
28
29
  }
@@ -215,6 +215,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
215
215
  canFilter: boolean;
216
216
  canSort: boolean;
217
217
  };
218
+ whatsappNotifyAddon: {
219
+ canFilter: boolean;
220
+ canSort: boolean;
221
+ };
218
222
  giftWrapAddon: {
219
223
  canFilter: boolean;
220
224
  canSort: boolean;
@@ -335,6 +339,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
335
339
  canFilter: boolean;
336
340
  canSort: boolean;
337
341
  };
342
+ whatsappNotifyAddon: {
343
+ canFilter: boolean;
344
+ canSort: boolean;
345
+ };
338
346
  giftWrapAddon: {
339
347
  canFilter: boolean;
340
348
  canSort: boolean;
@@ -1364,6 +1364,7 @@ OrderRepository.SELLER_SIEVE_FIELDS = {
1364
1364
  // Fulfilment flags. Filterable so "which of my orders need gift wrap?" is a
1365
1365
  // query against the orders themselves — the whole point of recording the
1366
1366
  // add-on on the order rather than keeping a side list of who asked for what.
1367
+ whatsappNotifyAddon: { canFilter: true, canSort: false },
1367
1368
  giftWrapAddon: { canFilter: true, canSort: false },
1368
1369
  shipmentProtectionAddon: { canFilter: true, canSort: false },
1369
1370
  saleProtectionAddon: { canFilter: true, canSort: false },
@@ -1396,6 +1397,7 @@ OrderRepository.ADMIN_SIEVE_FIELDS = {
1396
1397
  // Same fulfilment flags as SELLER_SIEVE_FIELDS — admin needs to answer
1397
1398
  // "who opted into WhatsApp updates?" when a status change goes out, without
1398
1399
  // maintaining a parallel tracker.
1400
+ whatsappNotifyAddon: { canFilter: true, canSort: false },
1399
1401
  giftWrapAddon: { canFilter: true, canSort: false },
1400
1402
  shipmentProtectionAddon: { canFilter: true, canSort: false },
1401
1403
  saleProtectionAddon: { canFilter: true, canSort: false },
@@ -822,6 +822,10 @@ export interface OrderDocument extends BaseDocument {
822
822
  codRemainingAmount?: number;
823
823
  /** COD handling fee charged to the buyer: min(subtotal × codHandlingFeePercent / 100, codHandlingFeeCap). Only set when paymentMethod === "cod". */
824
824
  codHandlingFee?: number;
825
+ /** Buyer opted into WhatsApp order-update messages at checkout (unchecked by default). */
826
+ whatsappNotifyAddon?: boolean;
827
+ /** Fee charged for the WhatsApp addon, decimal rupees — snapshot of siteSettings.commissions.whatsappNotifyFee at order time. */
828
+ whatsappNotifyFee?: number;
825
829
  /** Buyer opted into gift wrapping at checkout (unchecked by default). */
826
830
  giftWrapAddon?: boolean;
827
831
  /** Fee charged for gift wrap, decimal rupees — snapshot of siteSettings.commissions.giftWrapFee at order time. */
@@ -231,12 +231,15 @@ export interface Order {
231
231
  /**
232
232
  * Paid add-ons the buyer opted into for THIS order (= this store).
233
233
  *
234
- * These are operational flags, not just billing records:
235
- * `giftWrapAddon`/`shipmentProtectionAddon` are what
234
+ * These are operational flags, not just billing records: `whatsappNotifyAddon`
235
+ * is what the status-change notifier checks to decide whether to send a
236
+ * WhatsApp message, and `giftWrapAddon`/`shipmentProtectionAddon` are what
236
237
  * the packer needs to see before dispatch. They live on the order itself and
237
238
  * are filterable (`orders` SIEVE_FIELDS) precisely so nobody has to keep a
238
239
  * separate list of who asked for what.
239
240
  */
241
+ whatsappNotifyAddon?: boolean;
242
+ whatsappNotifyFee?: number;
240
243
  giftWrapAddon?: boolean;
241
244
  giftWrapFee?: number;
242
245
  /** The buyer's gift message — the packer has to physically include this. */
@@ -169,6 +169,15 @@ export const SITE_SETTINGS_ENTRIES = [
169
169
  tab: "notifications",
170
170
  kind: "toggle",
171
171
  },
172
+ {
173
+ slug: "whatsapp-notifications",
174
+ anchored: true,
175
+ label: "WhatsApp notifications",
176
+ description: "Send order updates over WhatsApp.",
177
+ keywords: ["whatsapp", "order updates", "meta"],
178
+ tab: "notifications",
179
+ kind: "toggle",
180
+ },
172
181
  {
173
182
  slug: "daily-digest",
174
183
  anchored: true,
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { BaseRepository } from "../../../providers/db-firebase";
16
16
  import type { DocumentSnapshot } from "firebase-admin/firestore";
17
- import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ItemRequestReply, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument } from "../schemas/firestore";
17
+ import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ItemRequestReply, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument, type StoreWhatsAppConfigDocument } from "../schemas/firestore";
18
18
  export declare class PayoutMethodsRepository extends BaseRepository<PayoutMethodDocument> {
19
19
  /**
20
20
  * Bank/UPI details, encrypted at rest by BaseRepository's write hooks.
@@ -103,6 +103,10 @@ export declare class ItemRequestsRepository extends BaseRepository<ItemRequestDo
103
103
  */
104
104
  appendReply(id: string, reply: ItemRequestReply): Promise<void>;
105
105
  }
106
+ export declare class StoreWhatsAppConfigRepository extends BaseRepository<StoreWhatsAppConfigDocument> {
107
+ constructor();
108
+ getByStore(storeId: string): Promise<StoreWhatsAppConfigDocument | null>;
109
+ }
106
110
  export declare class StoreGoogleConfigRepository extends BaseRepository<StoreGoogleConfigDocument> {
107
111
  constructor();
108
112
  getByStore(storeId: string): Promise<StoreGoogleConfigDocument | null>;
@@ -116,4 +120,5 @@ export declare const listingTemplatesRepository: ListingTemplatesRepository;
116
120
  export declare const moderationQueueRepository: ModerationQueueRepository;
117
121
  export declare const reportsRepository: ReportsRepository;
118
122
  export declare const itemRequestsRepository: ItemRequestsRepository;
123
+ export declare const storeWhatsAppConfigRepository: StoreWhatsAppConfigRepository;
119
124
  export declare const storeGoogleConfigRepository: StoreGoogleConfigRepository;
@@ -15,7 +15,7 @@
15
15
  import { BaseRepository } from "../../../providers/db-firebase/index.js";
16
16
  import { arrayUnion, increment } from "../../../contracts/field-ops.js";
17
17
  import { decryptPiiFields } from "../../../security/pii-encrypt.js";
18
- import { ANALYTICS_ALERTS_COLLECTION, ANALYTICS_CARDS_COLLECTION, ITEM_REQUESTS_COLLECTION, LISTING_TEMPLATES_COLLECTION, MODERATION_QUEUE_COLLECTION, PAYOUT_METHODS_COLLECTION, REPORTS_COLLECTION, SHIPPING_CONFIGS_COLLECTION, STORE_CATEGORIES_COLLECTION, STORE_GOOGLE_CONFIG_COLLECTION, } from "../schemas/firestore.js";
18
+ import { ANALYTICS_ALERTS_COLLECTION, ANALYTICS_CARDS_COLLECTION, ITEM_REQUESTS_COLLECTION, LISTING_TEMPLATES_COLLECTION, MODERATION_QUEUE_COLLECTION, PAYOUT_METHODS_COLLECTION, REPORTS_COLLECTION, SHIPPING_CONFIGS_COLLECTION, STORE_CATEGORIES_COLLECTION, STORE_GOOGLE_CONFIG_COLLECTION, STORE_WHATSAPP_CONFIG_COLLECTION, } from "../schemas/firestore.js";
19
19
  export class PayoutMethodsRepository extends BaseRepository {
20
20
  constructor() {
21
21
  super(PAYOUT_METHODS_COLLECTION);
@@ -149,6 +149,14 @@ export class ItemRequestsRepository extends BaseRepository {
149
149
  });
150
150
  }
151
151
  }
152
+ export class StoreWhatsAppConfigRepository extends BaseRepository {
153
+ constructor() {
154
+ super(STORE_WHATSAPP_CONFIG_COLLECTION);
155
+ }
156
+ async getByStore(storeId) {
157
+ return this.findOneBy("storeId", storeId);
158
+ }
159
+ }
152
160
  export class StoreGoogleConfigRepository extends BaseRepository {
153
161
  constructor() {
154
162
  super(STORE_GOOGLE_CONFIG_COLLECTION);
@@ -166,4 +174,5 @@ export const listingTemplatesRepository = new ListingTemplatesRepository();
166
174
  export const moderationQueueRepository = new ModerationQueueRepository();
167
175
  export const reportsRepository = new ReportsRepository();
168
176
  export const itemRequestsRepository = new ItemRequestsRepository();
177
+ export const storeWhatsAppConfigRepository = new StoreWhatsAppConfigRepository();
169
178
  export const storeGoogleConfigRepository = new StoreGoogleConfigRepository();
@@ -18,6 +18,7 @@ import type { BaseDocument } from "../../../_internal/shared/types/base-document
18
18
  * 7. moderationQueue — video / media moderation queue items
19
19
  * 8. reports — buyer-submitted reports against listings/stores
20
20
  * 9. itemRequests — community item request bulletin board
21
+ * 10. storeWhatsAppConfig — per-store WhatsApp business catalogue + auto-replies
21
22
  * 11. storeGoogleConfig — per-store Google Business reviews integration
22
23
  */
23
24
  export type PayoutMethodType = "upi" | "bank" | "card" | "other";
@@ -88,7 +89,7 @@ export interface AnalyticsAlertDocument extends BaseDocument {
88
89
  threshold: number;
89
90
  windowHours: number;
90
91
  isActive: boolean;
91
- notifyChannels: Array<"in-app" | "email">;
92
+ notifyChannels: Array<"in-app" | "email" | "whatsapp">;
92
93
  /**
93
94
  * When this alert last fired.
94
95
  *
@@ -220,6 +221,21 @@ export interface ItemRequestDocument extends BaseDocument {
220
221
  export declare const ITEM_REQUESTS_COLLECTION: "itemRequests";
221
222
  export declare const ITEM_REQUEST_INDEXED_FIELDS: readonly ["opUserId", "status", "category", "createdAt"];
222
223
  export declare const DEFAULT_ITEM_REQUEST_DATA: Partial<ItemRequestDocument>;
224
+ export interface StoreWhatsAppConfigDocument extends BaseDocument {
225
+ storeId: string;
226
+ isConnected: boolean;
227
+ isPaid: boolean;
228
+ phoneNumber?: string;
229
+ businessProfileName?: string;
230
+ catalogUrl?: string;
231
+ autoReply?: string;
232
+ welcomeMessage?: string;
233
+ /** Status of pending WhatsApp Business onboarding (Meta Cloud API). */
234
+ onboardingStatus?: "pending" | "approved" | "rejected";
235
+ }
236
+ export declare const STORE_WHATSAPP_CONFIG_COLLECTION: "storeWhatsAppConfig";
237
+ export declare const STORE_WHATSAPP_CONFIG_INDEXED_FIELDS: readonly ["storeId", "isConnected", "isPaid"];
238
+ export declare const DEFAULT_STORE_WHATSAPP_CONFIG_DATA: Partial<StoreWhatsAppConfigDocument>;
223
239
  export interface StoreGoogleConfigDocument extends BaseDocument {
224
240
  storeId: string;
225
241
  isConnected: boolean;