@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
@@ -32,6 +32,10 @@ export interface NotificationDocument extends BaseDocument {
32
32
  readAt?: Date;
33
33
  relatedId?: string;
34
34
  relatedType?: NotificationRelatedType;
35
+ /** Outcome of the async whatsappNotify job for this notification's WhatsApp send, if one was enqueued. */
36
+ whatsappStatus?: "queued" | "sent" | "failed" | "skipped";
37
+ /** jobs/{id} doc that ran (or is running) the WhatsApp send for this notification. */
38
+ whatsappJobId?: string;
35
39
  }
36
40
  export declare const NOTIFICATIONS_COLLECTION: "notifications";
37
41
  export declare const NOTIFICATIONS_INDEXED_FIELDS: readonly ["userId", "isRead", "createdAt", "type"];
@@ -110,6 +114,7 @@ export interface SiteSettingsCredentials {
110
114
  /** Webhook signing secret from Razorpay's dashboard → Webhooks. Without it `verifyWebhook()` fails closed. */
111
115
  razorpayWebhookSecret?: string;
112
116
  resendApiKey?: string;
117
+ whatsappApiKey?: string;
113
118
  metaAppId?: string;
114
119
  metaAppSecret?: string;
115
120
  metaPageAccessToken?: string;
@@ -124,6 +129,28 @@ export interface SiteSettingsCredentials {
124
129
  /** Google Places API for Google Business Reviews (HS4) */
125
130
  googleMapsApiKey?: string;
126
131
  googlePlaceId?: string;
132
+ /** WhatsApp Business Cloud API — platform level (for admin purchase announcements) */
133
+ whatsappPhoneNumberId?: string;
134
+ whatsappCloudApiToken?: string;
135
+ /** Comma-separated digits-only phone numbers that receive order announcements */
136
+ whatsappAdminNotifyNumbers?: string;
137
+ /**
138
+ * Approved Meta message-template names, one flat string field per
139
+ * order-lifecycle notification type — every `SiteSettingsCredentials`
140
+ * field is stored as an individually-encrypted string (see
141
+ * `getDecryptedCredentials`/`getCredentialsMasked`, which iterate + mask
142
+ * every field as a string), so this can't be a `Record<string,string>`
143
+ * map. Business-initiated WhatsApp sends outside the 24h customer-service
144
+ * window require a pre-approved template.
145
+ */
146
+ whatsappTemplateOrderPlaced?: string;
147
+ whatsappTemplateOrderConfirmed?: string;
148
+ whatsappTemplateOrderShipped?: string;
149
+ whatsappTemplateOrderDelivered?: string;
150
+ whatsappTemplateOrderCancelled?: string;
151
+ whatsappTemplateRefundInitiated?: string;
152
+ /** BCP-47 language code the templates above were approved in, e.g. "en" or "en_US". */
153
+ whatsappTemplateLanguage?: string;
127
154
  }
128
155
  export interface SiteSettingsCredentialsMasked {
129
156
  phonepeClientId?: string;
@@ -136,6 +163,7 @@ export interface SiteSettingsCredentialsMasked {
136
163
  razorpayKeySecret?: string;
137
164
  razorpayWebhookSecret?: string;
138
165
  resendApiKey?: string;
166
+ whatsappApiKey?: string;
139
167
  metaAppId?: string;
140
168
  metaAppSecret?: string;
141
169
  metaPageAccessToken?: string;
@@ -147,6 +175,16 @@ export interface SiteSettingsCredentialsMasked {
147
175
  deviantartClientSecret?: string;
148
176
  googleMapsApiKey?: string;
149
177
  googlePlaceId?: string;
178
+ whatsappPhoneNumberId?: string;
179
+ whatsappCloudApiToken?: string;
180
+ whatsappAdminNotifyNumbers?: string;
181
+ whatsappTemplateOrderPlaced?: string;
182
+ whatsappTemplateOrderConfirmed?: string;
183
+ whatsappTemplateOrderShipped?: string;
184
+ whatsappTemplateOrderDelivered?: string;
185
+ whatsappTemplateOrderCancelled?: string;
186
+ whatsappTemplateRefundInitiated?: string;
187
+ whatsappTemplateLanguage?: string;
150
188
  }
151
189
  export interface NotificationChannelConfig {
152
190
  inApp: {
@@ -162,6 +200,15 @@ export interface NotificationChannelConfig {
162
200
  /** Subset of NotificationTypes to email — absent means all types. */
163
201
  types?: NotificationType[];
164
202
  };
203
+ whatsapp: {
204
+ enabled: boolean;
205
+ /** Minimum priority that triggers a WhatsApp message (default: "high"). */
206
+ minPriority?: NotificationPriority;
207
+ /** Subset of NotificationTypes to WhatsApp — absent means all types. */
208
+ types?: NotificationType[];
209
+ /** When true, WhatsApp OTP is offered as an alternative to email OTP at checkout. */
210
+ otpEnabled?: boolean;
211
+ };
165
212
  sms: {
166
213
  enabled: boolean;
167
214
  /** Minimum priority that triggers an SMS (default: "high"). */
@@ -452,6 +499,29 @@ export interface SiteSettingsDocument extends BaseDocument {
452
499
  * ₹2,000 of "handling" on a ₹20,000 order — see `computeCodHandlingFee`.
453
500
  */ codHandlingFeeCap: number;
454
501
  codHandlingFeePercent: number;
502
+ /**
503
+ * Admin master toggle for the per-order, BUYER-paid WhatsApp addon.
504
+ *
505
+ * 🛑 Defaults to FALSE. The cost of WhatsApp moved to a standing monthly
506
+ * charge on the SELLER (`whatsappNotifyMonthlyFee` below) — the platform
507
+ * owns one number, so the expense is standing rather than per order, and a
508
+ * buyer should not have to purchase their own delivery updates. The
509
+ * checkout path that charges it is left intact rather than cut out again:
510
+ * it is woven through the order-total arithmetic that carries D42's
511
+ * round-once fix, and disabling it makes the fee resolve to 0 without
512
+ * touching that money code.
513
+ */
514
+ whatsappNotifyFeeEnabled: boolean;
515
+ /** Flat rupee fee charged to the buyer when they opt into the per-order addon. Unreachable while `whatsappNotifyFeeEnabled` is false. */
516
+ whatsappNotifyFee: number;
517
+ /**
518
+ * What a seller pays per month to receive WhatsApp order notifications —
519
+ * the admin-set price of `StoreDocument.whatsappNotifySubscribed`.
520
+ *
521
+ * Rupees per month, not per message: Meta bills us per template, but a
522
+ * seller cannot predict their own order count, and a per-message price
523
+ * would make their bill a function of how well they sold.
524
+ */ whatsappNotifyMonthlyFee: number;
455
525
  /** Admin master toggle — whether the gift-wrap addon is offered at checkout at all. */
456
526
  giftWrapFeeEnabled: boolean;
457
527
  /** Flat rupee fee charged to the buyer when they opt into gift wrap. */
@@ -537,6 +607,8 @@ export interface SiteSettingsDocument extends BaseDocument {
537
607
  messaging?: {
538
608
  /** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
539
609
  emailEnabled: boolean;
610
+ /** User-facing WhatsApp. Same exemption for staff. */
611
+ whatsappEnabled: boolean;
540
612
  /**
541
613
  * Reserved sends allowed per channel per day. Applies to staff and
542
614
  * transactional mail too — the ceiling exists to stay inside the
@@ -544,6 +616,7 @@ export interface SiteSettingsDocument extends BaseDocument {
544
616
  */
545
617
  dailyCeiling: {
546
618
  email: number;
619
+ whatsapp: number;
547
620
  sms: number;
548
621
  };
549
622
  };
@@ -15,7 +15,7 @@ export const ADMIN_CHECKOUT_BYPASS_FLAG_KEY = "adminCheckoutBypass";
15
15
  * · `constants/notification-types.ts` (9) — fed the admin filter chips and
16
16
  * the per-channel allow-list, so the admin list could not filter 18 of
17
17
  * the 27 real types and an admin literally could not allow-list
18
- * `offer_received` or `payment_review` for email
18
+ * `offer_received` or `payment_review` for email or WhatsApp
19
19
  * · `features/account/types/index.ts` (5: order|offer|promo|system|message)
20
20
  * — a different vocabulary entirely, on a `UserNotification` type with
21
21
  * zero real consumers
@@ -195,6 +195,7 @@ export const notificationQueryHelpers = {
195
195
  export const DEFAULT_NOTIFICATION_CHANNELS = {
196
196
  inApp: { enabled: true, readOnly: true },
197
197
  email: { enabled: false, minPriority: "normal" },
198
+ whatsapp: { enabled: false, minPriority: "high", otpEnabled: false },
198
199
  sms: { enabled: false, minPriority: "high" },
199
200
  };
200
201
  const PRIORITY_ORDER = {
@@ -254,7 +255,8 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
254
255
  // half-configured site.
255
256
  messaging: {
256
257
  emailEnabled: false,
257
- dailyCeiling: { email: 80, sms: 50 },
258
+ whatsappEnabled: false,
259
+ dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
258
260
  },
259
261
  emi: {
260
262
  enabled: false,
@@ -277,9 +279,12 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
277
279
  // The buyer add-ons ship ENABLED. They were `false`, which made
278
280
  // StoreAddonsPicker return null on both the cart and checkout, so a fresh
279
281
  // install had no add-ons at all and no indication why. `undefined` still
280
- // means disabled everywhere (computeGiftWrapFee et al. read
282
+ // means disabled everywhere (computeWhatsAppNotifyFee et al. read
281
283
  // `if (!rates.XEnabled) return 0`) — only the DEFAULT changed, so an
282
284
  // existing siteSettings document keeps whatever it already stored.
285
+ whatsappNotifyFeeEnabled: false,
286
+ whatsappNotifyFee: 10,
287
+ whatsappNotifyMonthlyFee: 199,
283
288
  giftWrapFeeEnabled: true,
284
289
  giftWrapFee: 49,
285
290
  shipmentProtectionFeeEnabled: true,
@@ -44,6 +44,6 @@ export interface CheckoutValueOtpDoc {
44
44
  * making the whole feature look broken.
45
45
  */
46
46
  export declare function enforceCheckoutValueOtpRateLimit(db: Db, uid: string): Promise<void>;
47
- /** Build the checkout-value OTP message body. */
47
+ /** Build the checkout-value OTP WhatsApp/SMS message body. */
48
48
  export declare function buildCheckoutValueOtpMessage(code: string, siteName: string): string;
49
49
  export {};
@@ -75,7 +75,7 @@ export async function enforceCheckoutValueOtpRateLimit(db, uid) {
75
75
  tx.set(metaRef, { lastSentAt: new Date() }, { merge: true });
76
76
  });
77
77
  }
78
- /** Build the checkout-value OTP message body. */
78
+ /** Build the checkout-value OTP WhatsApp/SMS message body. */
79
79
  export function buildCheckoutValueOtpMessage(code, siteName) {
80
80
  return (`*${siteName} — Order Verification*\n\n` +
81
81
  `Your verification code for this high-value order is: *${code}*\n\n` +
@@ -83,7 +83,7 @@ export declare const ROUTE_PERMISSION_MAP: Record<string, Permission>;
83
83
  *
84
84
  * Defaults on store creation: ["suggest_brands", "create_coupons"]
85
85
  */
86
- export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics";
86
+ export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics" | "whatsapp_catalog_sync";
87
87
  export declare const ALL_STORE_CAPABILITIES: StoreCapability[];
88
88
  /** Narrow an untrusted string to a real capability. */
89
89
  export declare function isStoreCapability(value: unknown): value is StoreCapability;
@@ -118,7 +118,7 @@ export declare function hasCapability(capabilities: StoreCapability[], required:
118
118
  *
119
119
  * 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
120
120
  * already holds the store document does not pay a second Firestore read to ask
121
- * what it is entitled to (Rule #6). Several routes were reading
121
+ * what it is entitled to (Rule #6). Three WhatsApp routes were reading
122
122
  * `store.capabilities ?? []` inline for exactly that reason, which made them a
123
123
  * second, divergent reader of the field — they skipped the default merge, so
124
124
  * they and `getStoreCapabilities` could disagree about the same store.
@@ -390,6 +390,7 @@ const STORE_CAPABILITY_MAP = {
390
390
  lower_commission_rate: true,
391
391
  early_access_features: true,
392
392
  advanced_analytics: true,
393
+ whatsapp_catalog_sync: true,
393
394
  };
394
395
  export const ALL_STORE_CAPABILITIES = Object.keys(STORE_CAPABILITY_MAP);
395
396
  /** Narrow an untrusted string to a real capability. */
@@ -441,7 +442,7 @@ export function hasCapability(capabilities, required) {
441
442
  *
442
443
  * 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
443
444
  * already holds the store document does not pay a second Firestore read to ask
444
- * what it is entitled to (Rule #6). Several routes were reading
445
+ * what it is entitled to (Rule #6). Three WhatsApp routes were reading
445
446
  * `store.capabilities ?? []` inline for exactly that reason, which made them a
446
447
  * second, divergent reader of the field — they skipped the default merge, so
447
448
  * they and `getStoreCapabilities` could disagree about the same store.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { blogRepository } from "../../../repositories/index.js";
3
- import { Container, Heading, Main, Section } from "../../../ui/index.js";
3
+ import { Container, Main, Section, PageHeroHeading } from "../../../ui/index.js";
4
4
  import { AdSlot } from "../../homepage/components/AdSlot.js";
5
5
  import { BlogIndexListing } from "./BlogIndexListing.js";
6
6
  import { BLOG_DEFAULT_PUBLIC_SORT } from "../schemas/firestore.js";
@@ -41,5 +41,5 @@ export async function BlogIndexPageView({ searchParams = {} }) {
41
41
  },
42
42
  }
43
43
  : undefined;
44
- return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Blog" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(BlogIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
44
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(PageHeroHeading, { className: "mb-8", pill: "From the shop floor", title: "Blog", description: "Buying guides, release notes and what we have learned importing and grading Beyblades.", meta: initialData?.meta?.total ? `${initialData.meta.total} articles` : undefined }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(BlogIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
45
45
  }
@@ -3,6 +3,7 @@ import React from "react";
3
3
  import Link from "next/link.js";
4
4
  import { Article, Div, Grid, Heading, OverflowRow, Pagination, Row, Span, Stack, Text } from "../../../ui/index.js";
5
5
  import { MediaImage } from "../../media/MediaImage.js";
6
+ import { blogCategoryBadge } from "../constants/category-badge.js";
6
7
  import { getDefaultLocale } from "../../../core/baseline-resolver.js";
7
8
  import { getMediaUrl } from "../../media/types/index.js";
8
9
  import { safeDisplayName } from "../../../security/index.js";
@@ -25,7 +26,7 @@ export function BlogCard({ post, href, onClick, className = "" }) {
25
26
  const isInteractive = !!(href || onClick);
26
27
  const card = (_jsxs(Article, { role: onClick && !href ? "button" : undefined, tabIndex: onClick && !href ? 0 : undefined, onKeyDown: onClick && !href
27
28
  ? (e) => (e.key === "Enter" || e.key === "") && onClick(post)
28
- : undefined, onClick: onClick && !href ? () => onClick(post) : undefined, className: `group flex flex-col h-full overflow-hidden rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md ${isInteractive ? "cursor-pointer" : ""} ${className}`, children: [_jsx(Div, { className: `relative aspect-video w-full ${__O.hidden} bg-[var(--appkit-color-surface)] dark:bg-[var(--appkit-color-surface-elevated)] flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", "aria-hidden": "true", children: "\u270D\uFE0F" }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p5}`, children: [_jsxs(Row, { className: "mb-2", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: "bg-primary/10 text-primary", rounded: "full", padding: "pill-xs", transform: "capitalize", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: "Featured" })), post.readTimeMinutes && (_jsxs(Span, { size: "xs", color: "muted", children: [post.readTimeMinutes, " min read"] }))] }), _jsx(Heading, { level: 3, className: `group-hover:text-primary`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: post.title }), post.excerpt && (_jsx(Text, { className: `mt-2 flex-1`, color: "muted", truncate: 3, size: "sm", children: post.excerpt })), _jsxs(Row, { className: "mt-auto", padding: "t-sm", gap: "3", children: [post.authorAvatar ? (_jsx(Div, { className: "relative h-7 w-7 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: post.authorAvatar, alt: post.authorName ?? "author", size: "avatar" }) })) : post.authorName ? (_jsx(Row, { textWeight: "bold", textSize: "xs", className: "h-7 w-7 flex-shrink-0 bg-primary/10 text-primary", align: "center", justify: "center", rounded: "full", children: post.authorName.charAt(0).toUpperCase() })) : null, _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] min-w-0", size: "xs", children: [post.authorName && (_jsx(Span, { weight: "medium", className: "text-neutral-700", children: safeDisplayName(post.authorName, "Author") })), date && _jsxs(Span, { className: "ml-1 text-neutral-400", children: ["\u00B7 ", date] })] })] })] })] }));
29
+ : undefined, onClick: onClick && !href ? () => onClick(post) : undefined, className: `group flex flex-col h-full overflow-hidden rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md ${isInteractive ? "cursor-pointer" : ""} ${className}`, children: [_jsx(Div, { className: `relative aspect-video w-full ${__O.hidden} bg-[var(--appkit-color-surface)] dark:bg-[var(--appkit-color-surface-elevated)] flex-shrink-0`, children: coverImageUrl ? (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", "aria-hidden": "true", children: "\u270D\uFE0F" }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p5}`, children: [_jsxs(Row, { className: "mb-2", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: blogCategoryBadge(post.category), rounded: "full", padding: "pill-xs", transform: "capitalize", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: "Featured" })), post.readTimeMinutes && (_jsxs(Span, { size: "xs", color: "muted", children: [post.readTimeMinutes, " min read"] }))] }), _jsx(Heading, { level: 3, className: `group-hover:text-primary`, color: "primary", truncate: 2, size: "base", weight: "semibold", children: post.title }), post.excerpt && (_jsx(Text, { className: `mt-2 flex-1`, color: "muted", truncate: 3, size: "sm", children: post.excerpt })), _jsxs(Row, { className: "mt-auto", padding: "t-sm", gap: "3", children: [post.authorAvatar ? (_jsx(Div, { className: "relative h-7 w-7 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: post.authorAvatar, alt: post.authorName ?? "author", size: "avatar" }) })) : post.authorName ? (_jsx(Row, { textWeight: "bold", textSize: "xs", className: "h-7 w-7 flex-shrink-0 bg-primary/10 text-primary", align: "center", justify: "center", rounded: "full", children: post.authorName.charAt(0).toUpperCase() })) : null, _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] min-w-0", size: "xs", children: [post.authorName && (_jsx(Span, { weight: "medium", className: "text-neutral-700", children: safeDisplayName(post.authorName, "Author") })), date && _jsxs(Span, { className: "ml-1 text-neutral-400", children: ["\u00B7 ", date] })] })] }), _jsxs(Row, { align: "center", gap: "xs", className: "mt-3 text-primary", textSize: "sm", textWeight: "medium", children: [_jsx(Span, { children: "Read" }), _jsx(Span, { "aria-hidden": "true", className: "transition-transform duration-200 group-hover:translate-x-1", children: "\u2192" })] })] })] }));
29
30
  if (href) {
30
31
  return (_jsx(Link, { href: href, className: "block h-full", children: card }));
31
32
  }
@@ -5,6 +5,7 @@ import Link from "next/link.js";
5
5
  import { Div, Grid, Heading, RichText, Row, Scrim, Span, Stack, Text } from "../../../ui/index.js";
6
6
  import { Iframe } from "../../../ui/components/Iframe.js";
7
7
  import { MediaImage } from "../../media/MediaImage.js";
8
+ import { blogCategoryBadge } from "../constants/category-badge.js";
8
9
  import { useBlogPost } from "../hooks/useBlog.js";
9
10
  import { BlogCard } from "./BlogListView.js";
10
11
  import { getMediaUrl } from "../../media/types/index.js";
@@ -56,5 +57,5 @@ export function BlogPostView({ slug, initialData, labels = {}, renderImage, rend
56
57
  })
57
58
  : "";
58
59
  const coverImageUrl = getMediaUrl(post.coverImage);
59
- return (_jsxs(Div, { className: `min-h-screen ${className}`, children: [coverImageUrl && (_jsxs(Div, { className: `relative h-72 md:h-96 ${__O.hidden}`, children: [renderImage ? (renderImage(post)) : (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "hero" })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-0" })] })), _jsxs(Div, { className: "max-w-3xl mx-auto", paddingY: "y-3xl", paddingX: "x-md", children: [renderBlogPostHeader(post, date, labels), post.youtubeId && /^[A-Za-z0-9_-]{6,20}$/.test(post.youtubeId) && (_jsx(Div, { className: "mb-8", children: _jsx(Iframe, { src: `https://www.youtube-nocookie.com/embed/${post.youtubeId}`, title: `${post.title} — video`, aspect: "video", rounded: "lg" }) })), post.tags && post.tags.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mb-8", children: post.tags.map((tag) => (_jsxs(Link, { href: `${String(ROUTES.BLOG.LIST)}?tags=${encodeURIComponent(tag)}`, className: "inline-block px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] rounded-full bg-[var(--appkit-color-surface-elevated)] hover:bg-primary/10 text-[var(--appkit-color-text-muted)] hover:text-primary text-[length:var(--appkit-text-xs)] font-medium transition-colors", children: ["#", tag] }, tag))) })), renderAuthorBio && _jsx(Div, { className: "mb-6", children: renderAuthorBio(post) }), _jsx(Div, { surface: "card", padding: "xl", className: "mb-12", children: renderContent ? renderContent(post) : (_jsx(RichText, { html: normalizeRichTextHtml(post.content ?? ""), proseClass: "prose max-w-none dark:prose-invert" })) }), _jsxs(Stack, { gap: "xl", children: [renderBlogPostRelated(related, labels?.relatedTitle ?? "Related Posts", renderRelatedCard), renderBlogPostRelated(relatedByTags, labels?.relatedByTagsTitle ?? "You might also like", renderRelatedCard), renderBlogPostRelated(relatedByAuthor, labels?.relatedByAuthorTitle ?? `More from ${post.authorName}`, renderRelatedCard)] }), renderBackButton && _jsx(Div, { border: "default", className: "mt-10 border-t", padding: "t-xl", children: renderBackButton() })] })] }));
60
+ return (_jsxs(Div, { className: `min-h-screen ${className}`, children: [coverImageUrl && (_jsxs(Div, { className: `relative h-72 md:h-[28rem] ${__O.hidden}`, children: [renderImage ? (renderImage(post)) : (_jsx(MediaImage, { src: coverImageUrl, alt: post.title, size: "hero", objectFit: "cover" })), _jsx(Scrim, { direction: "bottom-up", intensity: "strong", className: "absolute inset-0" }), _jsx(Div, { className: "absolute inset-x-0 bottom-0", children: _jsxs(Stack, { className: "mx-auto max-w-3xl", gap: "sm", paddingX: "x-md", paddingY: "y-xl", children: [_jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-block capitalize ${blogCategoryBadge(post.category)}`, rounded: "full", padding: "pill-xs", children: post.category }), post.isFeatured && (_jsx(Span, { size: "xs", weight: "medium", className: CLS_FEATURED_BADGE, children: labels?.featured ?? "Featured" }))] }), _jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", color: "inverse", shadow: "lg", children: post.title }), _jsxs(Row, { color: "inverse", textSize: "sm", wrap: true, gap: "md", className: "opacity-90", children: [post.authorName && (_jsxs(Span, { children: [labels?.author ?? "By", " ", _jsx(Span, { weight: "medium", children: post.authorName })] })), post.readTimeMinutes != null && (_jsxs(Span, { children: [post.readTimeMinutes, " ", labels?.readTime ?? "min read"] })), date && _jsx(Span, { children: date })] })] }) })] })), _jsxs(Div, { className: "max-w-3xl mx-auto", paddingY: "y-3xl", paddingX: "x-md", children: [!coverImageUrl && renderBlogPostHeader(post, date, labels), post.youtubeId && /^[A-Za-z0-9_-]{6,20}$/.test(post.youtubeId) && (_jsx(Div, { className: "mb-8", children: _jsx(Iframe, { src: `https://www.youtube-nocookie.com/embed/${post.youtubeId}`, title: `${post.title} — video`, aspect: "video", rounded: "lg" }) })), post.tags && post.tags.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mb-8", children: post.tags.map((tag) => (_jsxs(Link, { href: `${String(ROUTES.BLOG.LIST)}?tags=${encodeURIComponent(tag)}`, className: "inline-block px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] rounded-full bg-[var(--appkit-color-surface-elevated)] hover:bg-primary/10 text-[var(--appkit-color-text-muted)] hover:text-primary text-[length:var(--appkit-text-xs)] font-medium transition-colors", children: ["#", tag] }, tag))) })), renderAuthorBio && _jsx(Div, { className: "mb-6", children: renderAuthorBio(post) }), _jsx(Div, { surface: "card", padding: "xl", className: "mb-12", children: renderContent ? renderContent(post) : (_jsx(RichText, { html: normalizeRichTextHtml(post.content ?? ""), proseClass: "prose max-w-none dark:prose-invert" })) }), renderBackButton && _jsx(Div, { border: "default", className: "mt-10 border-t", padding: "t-xl", children: renderBackButton() })] }), _jsx(Div, { className: "mx-auto max-w-6xl", paddingX: "x-md", paddingY: "y-2xl", children: _jsxs(Stack, { gap: "xl", children: [renderBlogPostRelated(related, labels?.relatedTitle ?? "Related Posts", renderRelatedCard), renderBlogPostRelated(relatedByTags, labels?.relatedByTagsTitle ?? "You might also like", renderRelatedCard), renderBlogPostRelated(relatedByAuthor, labels?.relatedByAuthorTitle ?? `More from ${post.authorName}`, renderRelatedCard)] }) })] }));
60
61
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The per-category chip palette, shared by every blog surface.
3
+ *
4
+ * ## Why this moved out of `BlogFeaturedCard`
5
+ *
6
+ * There were two palettes and only one of them had colours. `BlogFeaturedCard`
7
+ * carried this real five-way map — and that component is mounted ONLY on the
8
+ * homepage rail. `BlogCard`, which is what `/blog` and every related-posts
9
+ * grid actually render, painted all five categories one flat
10
+ * `bg-primary/10 text-primary`.
11
+ *
12
+ * So the index — the page where telling a guide from a release note is worth
13
+ * something, because you are scanning twenty of them — was the one surface
14
+ * with no category colour at all.
15
+ *
16
+ * 🛑 `Record<BlogPostCategory, string>`, so a sixth category is a compile
17
+ * error rather than a card that silently falls back to grey.
18
+ *
19
+ * @tag domain:blog
20
+ * @tag layer:constants
21
+ */
22
+ import type { BlogPostCategory } from "../types";
23
+ export declare const BLOG_CATEGORY_BADGE: Record<BlogPostCategory, string>;
24
+ /**
25
+ * Neutral fallback for a value that is not a known category.
26
+ *
27
+ * 🛑 Both halves are theme-relative tokens that invert together. Never pair a
28
+ * `-surface` tint with a literal `text-white`: `-surface` is a near-white tint
29
+ * in light themes and a dark tint in dark ones, so fixed-white ink is
30
+ * invisible in exactly one of them (Root Cause #67).
31
+ */
32
+ export declare const BLOG_CATEGORY_BADGE_FALLBACK = "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]";
33
+ export declare function blogCategoryBadge(category: string | undefined): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The per-category chip palette, shared by every blog surface.
3
+ *
4
+ * ## Why this moved out of `BlogFeaturedCard`
5
+ *
6
+ * There were two palettes and only one of them had colours. `BlogFeaturedCard`
7
+ * carried this real five-way map — and that component is mounted ONLY on the
8
+ * homepage rail. `BlogCard`, which is what `/blog` and every related-posts
9
+ * grid actually render, painted all five categories one flat
10
+ * `bg-primary/10 text-primary`.
11
+ *
12
+ * So the index — the page where telling a guide from a release note is worth
13
+ * something, because you are scanning twenty of them — was the one surface
14
+ * with no category colour at all.
15
+ *
16
+ * 🛑 `Record<BlogPostCategory, string>`, so a sixth category is a compile
17
+ * error rather than a card that silently falls back to grey.
18
+ *
19
+ * @tag domain:blog
20
+ * @tag layer:constants
21
+ */
22
+ export const BLOG_CATEGORY_BADGE = {
23
+ news: "bg-info-surface text-info",
24
+ tips: "bg-success-surface text-success",
25
+ guides: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300",
26
+ updates: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
27
+ community: "bg-warning-surface text-warning",
28
+ };
29
+ /**
30
+ * Neutral fallback for a value that is not a known category.
31
+ *
32
+ * 🛑 Both halves are theme-relative tokens that invert together. Never pair a
33
+ * `-surface` tint with a literal `text-white`: `-surface` is a near-white tint
34
+ * in light themes and a dark tint in dark ones, so fixed-white ink is
35
+ * invisible in exactly one of them (Root Cause #67).
36
+ */
37
+ export const BLOG_CATEGORY_BADGE_FALLBACK = "bg-[var(--appkit-color-bg)] text-[var(--appkit-color-text-muted)]";
38
+ export function blogCategoryBadge(category) {
39
+ return (BLOG_CATEGORY_BADGE[category] ?? BLOG_CATEGORY_BADGE_FALLBACK);
40
+ }
@@ -22,6 +22,7 @@ export interface CartPriceBreakdownStore {
22
22
  subtotal: number;
23
23
  shippingFee: number;
24
24
  codHandlingFee: number;
25
+ whatsappNotifyFee: number;
25
26
  giftWrapFee: number;
26
27
  shipmentProtectionFee: number;
27
28
  saleProtectionFee: number;
@@ -44,6 +45,7 @@ export interface CartPriceBreakdownData {
44
45
  subtotal: number;
45
46
  shippingFee: number;
46
47
  codHandlingFee: number;
48
+ whatsappNotifyFee: number;
47
49
  giftWrapFee: number;
48
50
  shipmentProtectionFee: number;
49
51
  saleProtectionFee: number;
@@ -40,7 +40,7 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
40
40
  const subtotalLabel = itemCount !== undefined
41
41
  ? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
42
42
  : "Subtotal";
43
- return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
43
+ return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.whatsappNotifyFee > 0 && (_jsx(Line, { label: withStoreCount("WhatsApp updates", countCharged((s) => s.whatsappNotifyFee)), amount: money(preview.whatsappNotifyFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
44
44
  ? "Your points could not be applied to this order."
45
45
  : storeCount > 1
46
46
  ? "None of these sellers accepts points."
@@ -3,12 +3,13 @@ import React from "react";
3
3
  * Paid add-on checkboxes for ONE store.
4
4
  *
5
5
  * Add-on fees are billed per order group (= per store), so they are chosen per
6
- * store: ticking "gift wrap" for one seller must not bill it for every
6
+ * store: ticking "WhatsApp updates" for one seller must not bill it for every
7
7
  * seller in the cart. Rendered inside that store's card in the cart, and inside
8
8
  * that store's row of the checkout summary — Buy Now skips the cart entirely,
9
9
  * so both surfaces have to be able to edit them.
10
10
  */
11
11
  export interface StoreAddonsValue {
12
+ whatsappNotifyAddon?: boolean;
12
13
  giftWrapAddon?: boolean;
13
14
  giftWrapMessage?: string;
14
15
  shipmentProtectionAddon?: boolean;
@@ -16,6 +17,8 @@ export interface StoreAddonsValue {
16
17
  }
17
18
  /** The subset of `siteSettings.commissions` that gates and prices the add-ons. */
18
19
  export interface StoreAddonsRates {
20
+ whatsappNotifyFeeEnabled?: boolean;
21
+ whatsappNotifyFee?: number;
19
22
  giftWrapFeeEnabled?: boolean;
20
23
  giftWrapFee?: number;
21
24
  shipmentProtectionFeeEnabled?: boolean;
@@ -56,7 +59,7 @@ export interface StoreProtectionContext {
56
59
  * showing an explanatory line instead.
57
60
  *
58
61
  * 🛑 Deliberately asks only about TIER 1. A seller who supports neither
59
- * protection still has gift wrap, and a store whose only
62
+ * protection still has WhatsApp updates and gift wrap, and a store whose only
60
63
  * enabled add-ons are protections it declined must still render the picker —
61
64
  * because that is where the risk notice lives, and a hidden notice is the
62
65
  * tri-state failure in § `StoreProtectionContext`.
@@ -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, ShieldOff } from "lucide-react";
3
+ import { Gift, MessageCircle, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
4
4
  import { Div, Row, Stack, Text } from "../../../ui/index.js";
5
5
  import { Disclaimer } from "../../../ui/components/Disclaimer.js";
6
6
  import { IconBox } from "../../../ui/components/IconBox.js";
@@ -16,14 +16,15 @@ import { formatCurrency } from "../../../utils/number.formatter.js";
16
16
  * showing an explanatory line instead.
17
17
  *
18
18
  * 🛑 Deliberately asks only about TIER 1. A seller who supports neither
19
- * protection still has gift wrap, and a store whose only
19
+ * protection still has WhatsApp updates and gift wrap, and a store whose only
20
20
  * enabled add-ons are protections it declined must still render the picker —
21
21
  * because that is where the risk notice lives, and a hidden notice is the
22
22
  * tri-state failure in § `StoreProtectionContext`.
23
23
  */
24
24
  export function hasAnyStoreAddon(rates) {
25
25
  return (!!rates &&
26
- !!(rates.giftWrapFeeEnabled ||
26
+ !!(rates.whatsappNotifyFeeEnabled ||
27
+ rates.giftWrapFeeEnabled ||
27
28
  rates.shipmentProtectionFeeEnabled ||
28
29
  rates.saleProtectionFeeEnabled));
29
30
  }
@@ -51,7 +52,7 @@ export function StoreAddonsPicker({ storeId, storeSubtotal, value, onChange, rat
51
52
  const shippingDeclined = !!rates.shipmentProtectionFeeEnabled && !protection?.supportsShippingProtection;
52
53
  const saleDeclined = !!rates.saleProtectionFeeEnabled && !protection?.supportsSaleProtection;
53
54
  const saleBlockedByFinalSale = !!rates.saleProtectionFeeEnabled && !!protection?.supportsSaleProtection && !!protection?.hasFinalSaleItem;
54
- return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
55
+ return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.whatsappNotifyFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(MessageCircle, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `whatsappNotifyAddon-${storeId}`, label: `WhatsApp order updates (+${money(rates.whatsappNotifyFee ?? 10)})`, checked: !!value.whatsappNotifyAddon, onChange: (checked) => set({ whatsappNotifyAddon: checked }), disabled: disabled }) })] })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
55
56
  ? "This seller does not offer shipment or sale protection."
56
57
  : shippingDeclined
57
58
  ? "This seller does not offer shipment protection."
@@ -577,7 +577,8 @@ export class CartRepository extends BaseRepository {
577
577
  */
578
578
  async setStoreAddons(userId, storeId, addons) {
579
579
  const isEmpty = !addons ||
580
- (!addons.giftWrapAddon &&
580
+ (!addons.whatsappNotifyAddon &&
581
+ !addons.giftWrapAddon &&
581
582
  !addons.shipmentProtectionAddon &&
582
583
  !addons.saleProtectionAddon);
583
584
  await this.db
@@ -272,11 +272,12 @@ export interface CartAppliedCoupon {
272
272
  *
273
273
  * Add-on fees have always been billed per order group (= per store) by
274
274
  * `createOrderForGroup` / `previewCheckoutPricing`, but the selection used to be
275
- * a single cart-wide boolean — so one tick on "gift wrap" quietly billed
275
+ * a single cart-wide boolean — so one tick on "WhatsApp updates" quietly billed
276
276
  * ₹10 × every store in the cart, with no way to opt in for just one seller.
277
277
  * Keying the selection by storeId makes the choice match the billing.
278
278
  */
279
279
  export interface CartStoreAddons {
280
+ whatsappNotifyAddon?: boolean;
280
281
  giftWrapAddon?: boolean;
281
282
  giftWrapMessage?: string;
282
283
  /** Loss or damage IN TRANSIT. */
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+ /**
3
+ * A plain, server-rendered index of entity links.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Measured on the prerendered HTML 2026-10-24: `/categories`, `/brands` and
8
+ * `/parts` each rendered **zero** outbound entity links, and `/franchise`
9
+ * rendered one. All three hubs filter and paginate entirely client-side
10
+ * (`useCategoriesFiltered`), which is a deliberate choice that keeps them
11
+ * static — but it means a crawler that lands on `/brands` finds nothing to
12
+ * follow, and the 28 brand pages are reachable only from the homepage strip.
13
+ * Franchise detail pages were reachable by no link at all.
14
+ *
15
+ * This renders the full set as real `<a href>`s in the server HTML, below the
16
+ * interactive grid. It is additive: the client grid above is untouched, so the
17
+ * filtering UX is unchanged and the page stays static (a Firestore read in a
18
+ * Server Component does not force dynamic rendering — only `searchParams`,
19
+ * `cookies()` and `headers()` do, Root Cause #94).
20
+ *
21
+ * It is also genuinely useful rather than an SEO artefact — an A–Z index is
22
+ * what a visitor who wants "all of them" is looking for, and hiding every
23
+ * entity behind a JS filter serves nobody. Nothing here is cloaked: the links
24
+ * are visible to a person exactly as they are to a crawler.
25
+ *
26
+ * 🛑 Deliberately NOT a `"use client"` component and deliberately not
27
+ * paginated. Its whole job is to be in the HTML.
28
+ */
29
+ export interface TaxonomyLink {
30
+ href: string;
31
+ label: string;
32
+ }
33
+ export interface TaxonomyLinkIndexProps {
34
+ heading: string;
35
+ /** One short sentence saying what the list is. Rendered above the links. */
36
+ blurb?: string;
37
+ items: readonly TaxonomyLink[];
38
+ }
39
+ export declare function TaxonomyLinkIndex({ heading, blurb, items }: TaxonomyLinkIndexProps): React.JSX.Element | null;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { TextLink } from "../../../ui/components/TextLink.js";
3
+ import { Section } from "../../../ui/components/Semantic.js";
4
+ import { Container, Row } from "../../../ui/components/Layout.js";
5
+ import { Heading, Text } from "../../../ui/components/Typography.js";
6
+ export function TaxonomyLinkIndex({ heading, blurb, items }) {
7
+ // An empty index is worse than none — it reads as a broken section.
8
+ if (items.length === 0)
9
+ return null;
10
+ return (_jsx(Section, { padding: "y-xl", surface: "muted", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", className: "mb-2", children: heading }), blurb ? (_jsx(Text, { size: "sm", color: "muted", className: "mb-4", children: blurb })) : null, _jsx(Row, { wrap: true, gap: "comfortable", as: "nav", "aria-label": heading, children: items.map((item) => (_jsx(TextLink, { href: item.href, children: item.label }, item.href))) })] }) }));
11
+ }
@@ -185,6 +185,31 @@ export interface CategoryDocument extends BaseDocument {
185
185
  * ranking mechanism; no Shopify competitor in the crawl has any.
186
186
  */
187
187
  contentBody?: string;
188
+ /**
189
+ * Where this category's SEO signals belong, when that is NOT its own URL.
190
+ *
191
+ * A part-leaf category and a `/parts/{slot}/{code}` page are the same
192
+ * entity seen twice: the leaf is a (usually empty) shopping surface, the
193
+ * parts page is the reference entry with the weight, code, aliases, WBO
194
+ * legality and the models that use it. Measured 2026-10-24 the taxonomy
195
+ * held 4,698 categories of which exactly **20** had any stock, so for the
196
+ * overwhelming majority the parts page is the only one with content.
197
+ *
198
+ * Setting this makes the category page declare the parts page canonical, so
199
+ * the two stop competing and the signals consolidate onto the one a visitor
200
+ * actually wants.
201
+ *
202
+ * 🛑 Written by `scripts/gen-category-parts.mjs`, which is the ONLY place
203
+ * that knows both ids — it assigns the category id and the glossary id in
204
+ * the same loop (the pairing `PART_CATEGORY_TO_GLOSSARY_ID` records). Do not
205
+ * re-derive it at a call site from the slug: the id schemes differ by
206
+ * construction and the collision-qualification rule is not invertible.
207
+ *
208
+ * 🛑 A root-relative path (`/parts/disc/0-0wall`), never absolute — the
209
+ * canonical HOST has exactly one definition (appkit.config.js `seo.siteUrl`)
210
+ * and a second one here is Root Cause #81 waiting to happen.
211
+ */
212
+ seoCanonicalPath?: string;
188
213
  /** Condition-keyed description bodies. A SET, because WHS reuses two per category. */
189
214
  descriptionTemplates?: CategoryDescriptionTemplate[];
190
215
  /** What this category hands down to a product filed under it. */