@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,33 @@
1
+ /**
2
+ * `loadOptions` adapters for `<PaginatedSelect>` over the PUBLIC taxonomy
3
+ * endpoints.
4
+ *
5
+ * 🛑 Extracted 2026-10-24 because there were already THREE independent copies
6
+ * of this function — `AdminCategoryEditorView`, `AdminProductEditorView` and
7
+ * `CatalogueItemEditorView` — which is the Duplication Framework's Rule of
8
+ * Three trigger, and a fourth was about to be written for the store-creation
9
+ * form. The body below is lifted from the catalogue copy, which is the only
10
+ * one that got both details right (see the two 🛑 notes).
11
+ *
12
+ * The three existing copies are deliberately NOT migrated here: each lives in
13
+ * an editor another session may hold open, and a shared helper with one honest
14
+ * consumer is a better starting point than a four-way edit. They remain as the
15
+ * migration backlog.
16
+ */
17
+ export interface TaxonomyOptionPage {
18
+ items: {
19
+ value: string;
20
+ label: string;
21
+ }[];
22
+ hasMore: boolean;
23
+ }
24
+ /**
25
+ * 🛑 `listingOnly=true` is load-bearing. Without it the picker offers brand
26
+ * and bundle rows as categories — measured against production: "Takara-Tomy",
27
+ * "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
28
+ * collection, and a plain listing category OMITS `categoryType` entirely, so
29
+ * the test cannot be expressed as a query clause; the route applies it in
30
+ * memory.
31
+ */
32
+ export declare const loadCategoryOptions: (q: string, p: number) => Promise<TaxonomyOptionPage>;
33
+ export declare const loadBrandOptions: (q: string, p: number) => Promise<TaxonomyOptionPage>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `loadOptions` adapters for `<PaginatedSelect>` over the PUBLIC taxonomy
3
+ * endpoints.
4
+ *
5
+ * 🛑 Extracted 2026-10-24 because there were already THREE independent copies
6
+ * of this function — `AdminCategoryEditorView`, `AdminProductEditorView` and
7
+ * `CatalogueItemEditorView` — which is the Duplication Framework's Rule of
8
+ * Three trigger, and a fourth was about to be written for the store-creation
9
+ * form. The body below is lifted from the catalogue copy, which is the only
10
+ * one that got both details right (see the two 🛑 notes).
11
+ *
12
+ * The three existing copies are deliberately NOT migrated here: each lives in
13
+ * an editor another session may hold open, and a shared helper with one honest
14
+ * consumer is a better starting point than a four-way edit. They remain as the
15
+ * migration backlog.
16
+ */
17
+ import { apiClient } from "../../../http/index.js";
18
+ import { CATEGORY_ENDPOINTS, BRAND_ENDPOINTS } from "../../../constants/api-endpoints.js";
19
+ /**
20
+ * 🛑 PUBLIC endpoints only. A seller-facing picker that reached
21
+ * `/api/admin/categories` would 403 for the very users it exists for, and the
22
+ * failure renders as an empty dropdown rather than an error.
23
+ */
24
+ async function loadOptionsFrom(endpoint, query, page,
25
+ /**
26
+ * Extra query params, passed as an object rather than baked into `endpoint`:
27
+ * this builds its own `?`, so a pre-existing query string would produce
28
+ * `...?listingOnly=true?page=1`.
29
+ */
30
+ extra) {
31
+ const params = new URLSearchParams({ page: String(page), pageSize: "25", ...extra });
32
+ if (query)
33
+ params.set("q", query);
34
+ const res = await apiClient.get(`${endpoint}?${params.toString()}`);
35
+ const data = res ?? {};
36
+ return {
37
+ items: (data.items ?? []).map((c) => ({ value: c.id, label: c.name })),
38
+ hasMore: data.hasMore ?? false,
39
+ };
40
+ }
41
+ /**
42
+ * 🛑 `listingOnly=true` is load-bearing. Without it the picker offers brand
43
+ * and bundle rows as categories — measured against production: "Takara-Tomy",
44
+ * "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
45
+ * collection, and a plain listing category OMITS `categoryType` entirely, so
46
+ * the test cannot be expressed as a query clause; the route applies it in
47
+ * memory.
48
+ */
49
+ export const loadCategoryOptions = (q, p) => loadOptionsFrom(CATEGORY_ENDPOINTS.LIST, q, p, { listingOnly: "true" });
50
+ export const loadBrandOptions = (q, p) => loadOptionsFrom(BRAND_ENDPOINTS.LIST, q, p);
@@ -6,17 +6,12 @@
6
6
  * purpose from `checkout-actions.ts`'s third-party shipping consent OTP —
7
7
  * same crypto primitives, separate Firestore namespace and copy.
8
8
  */
9
+ export type CheckoutValueOtpChannel = "email" | "whatsapp";
9
10
  /**
10
- * Email is the only checkout-OTP channel. WhatsApp was the other arm until
11
- * 2026-10-20, when the Cloud API integration was removed — see
12
- * `features/orders/utils/payment-proof-message.ts` for why the `wa.me`
13
- * links survived and the API did not. Kept as a single-member union rather
14
- * than deleted so the parameter stays self-documenting and a second channel
15
- * has an obvious seam.
16
- */
17
- export type CheckoutValueOtpChannel = "email";
18
- /**
19
- * Sends a checkout-value OTP by email.
11
+ * Sends a checkout-value OTP either by email (default) or WhatsApp
12
+ * (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
13
+ * is always resolved server-side from the selected shipping address —
14
+ * never trust a client-supplied phone number.
20
15
  *
21
16
  * If the chosen channel (and, for email, its skip-worthy fallback) is
22
17
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -9,12 +9,17 @@
9
9
  import { timingSafeEqual } from "crypto";
10
10
  import { ValidationError } from "../../../errors/index.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
+ import { safeRead } from "../../../errors/safe-read.js";
12
13
  import { serverLogger } from "../../../monitoring/index.js";
13
14
  import { sendEmail } from "../../contact/email.js";
14
15
  import { getAdminDb } from "../../../providers/db-firebase/index.js";
15
16
  import { resolveDate } from "../../../utils/index.js";
17
+ import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
18
+ import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
19
+ import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
16
20
  import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
17
- import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, } from "../../auth/checkout-value-otp.js";
21
+ import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
22
+ import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
18
23
  function maskEmail(userEmail) {
19
24
  const [local, domain] = userEmail.split("@");
20
25
  return domain
@@ -22,7 +27,10 @@ function maskEmail(userEmail) {
22
27
  : "***";
23
28
  }
24
29
  /**
25
- * Sends a checkout-value OTP by email.
30
+ * Sends a checkout-value OTP either by email (default) or WhatsApp
31
+ * (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
32
+ * is always resolved server-side from the selected shipping address —
33
+ * never trust a client-supplied phone number.
26
34
  *
27
35
  * If the chosen channel (and, for email, its skip-worthy fallback) is
28
36
  * currently circuit-broken or unconfigured, the OTP gate is treated as
@@ -37,6 +45,84 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
37
45
  const codeHash = hashOtp(code);
38
46
  const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
39
47
  const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
48
+ if (channel === "whatsapp") {
49
+ if (!addressId) {
50
+ throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
51
+ }
52
+ // A failed settings read makes the WhatsApp channel look deliberately
53
+ // switched off ("send the code by email instead") — a defensible fallback,
54
+ // but one an operator has to be able to see, since it is indistinguishable
55
+ // from an admin having disabled it.
56
+ const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
57
+ route: "checkout/value-otp",
58
+ key: "checkoutValueOtp.siteSettings",
59
+ fallback: null,
60
+ });
61
+ const creds = settings?.credentials ?? {};
62
+ const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
63
+ const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
64
+ const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
65
+ const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
66
+ if (!whatsappOtpEnabled || !credsReady) {
67
+ throw new ValidationError("WhatsApp verification isn't available right now — send the code by email instead.");
68
+ }
69
+ const address = await addressesRepository.findById(addressId);
70
+ /*
71
+ * REVEALED: this phone receives the high-value checkout OTP, so it has to
72
+ * be the real number. `addressesRepository` is ciphertext-by-default, and
73
+ * `.trim()` on the stored value is what the nominal brand caught — the
74
+ * old intersection form let `Encrypted<string>` call string methods.
75
+ */
76
+ const addressPlain = address ? addressesRepository.revealAddress(address) : null;
77
+ const phone = addressPlain?.phone?.trim();
78
+ if (!phone) {
79
+ throw new ValidationError("The selected address has no phone number on file — send the code by email instead.");
80
+ }
81
+ if (!(await isChannelHealthy("whatsapp"))) {
82
+ serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy — skipping OTP gate", { uid: userId, channel });
83
+ return { skipped: true };
84
+ }
85
+ await checkoutValueOtpRef(db, userId).set({
86
+ codeHash,
87
+ expiresAt,
88
+ attempts: 0,
89
+ verified: false,
90
+ createdAt: new Date(),
91
+ });
92
+ const message = buildCheckoutValueOtpMessage(code, siteName);
93
+ // Same guard as the email branch below, same reasoning: transactional, so
94
+ // the kill switch does not apply (the buyer cannot check out without the
95
+ // code), but the daily ceiling does.
96
+ const waGuard = {
97
+ channel: "whatsapp",
98
+ feature: "checkout_value_otp",
99
+ audience: "user",
100
+ transactional: true,
101
+ };
102
+ const waDecision = await guardSend(waGuard);
103
+ if (!waDecision.allow) {
104
+ logSuppressedSend(waGuard, waDecision);
105
+ throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
106
+ }
107
+ try {
108
+ const sent = await withChannelRetry("whatsapp", async () => {
109
+ const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
110
+ if (!ok)
111
+ throw new Error("WhatsApp Cloud API returned a non-OK response");
112
+ return ok;
113
+ });
114
+ if (!sent)
115
+ throw new Error("WhatsApp send failed");
116
+ }
117
+ catch (err) {
118
+ void normalizeError(err);
119
+ serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
120
+ throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
121
+ }
122
+ const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
123
+ serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
124
+ return { maskedPhone };
125
+ }
40
126
  if (!(await isChannelHealthy("email"))) {
41
127
  serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy — skipping OTP gate", { uid: userId, channel });
42
128
  return { skipped: true };
@@ -6,6 +6,8 @@ import { MediaImage } from "../../media/MediaImage.js";
6
6
  import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
7
7
  import { EVENT_FIELDS } from "../schemas/index.js";
8
8
  import { EventStatusBadge } from "./EventStatusBadge.js";
9
+ import { EventTypeBadge } from "./EventTypeBadge.js";
10
+ import { eventTypeMeta } from "../constants/event-type-meta.js";
9
11
  import { ROUTES } from "../../../next/routing/route-map.js";
10
12
  import { useLongPress } from "../../../react/hooks/useLongPress.js";
11
13
  import { RecordOwnerQuickActions } from "../../admin/components/RecordOwnerQuickActions.js";
@@ -15,16 +17,6 @@ const __P = {
15
17
  const __O = {
16
18
  hidden: "overflow-hidden",
17
19
  };
18
- const TYPE_ICONS = {
19
- sale: "🏷️",
20
- offer: "🎁",
21
- poll: "📊",
22
- survey: "📝",
23
- feedback: "💬",
24
- raffle: "🎟️",
25
- spin_wheel: "🎡",
26
- lottery: "🎰",
27
- };
28
20
  export function EventCard({ event, labels = {}, onParticipate, className = "", selectable = false, isSelected = false, onSelect, renderOwnerActions, }) {
29
21
  const longPress = useLongPress(() => onSelect?.(event.id, !isSelected));
30
22
  const safeTitle = event.title?.trim() || "Untitled event";
@@ -33,8 +25,8 @@ export function EventCard({ event, labels = {}, onParticipate, className = "", s
33
25
  const msLeft = endsAt.getTime() - now.getTime();
34
26
  const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)));
35
27
  const detailHref = String(ROUTES.PUBLIC.EVENT_DETAIL(event.slug ?? event.id));
36
- const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: TYPE_ICONS[event.type] }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(Span, { size: "lg", "aria-hidden": "true", children: TYPE_ICONS[event.type] }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
37
- daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\uD83D\uDC65 ", event.stats.totalEntries, " ", labels.entries ?? "entries"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
28
+ const card = (_jsxs(Stack, { as: "article", border: "default", rounded: "xl", shadow: "hover-md", gap: "none", className: `group relative h-full overflow-hidden bg-[var(--appkit-color-surface)] ${isSelected ? "border-primary outline outline-2 outline-primary" : " "} ${className}`, onMouseDown: onSelect && !isSelected ? longPress.onMouseDown : undefined, onMouseUp: onSelect && !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: onSelect && !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: onSelect && !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: onSelect && !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: onSelect && !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(event.id, !isSelected); }, label: isSelected ? "Deselect event" : "Select event", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Link, { href: detailHref, className: "block flex-shrink-0", children: event.coverImageUrl || event.coverImage?.url ? (_jsx(Div, { className: `relative aspect-video ${__O.hidden}`, children: _jsx(MediaImage, { src: event.coverImageUrl || event.coverImage?.url || "", alt: safeTitle, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Row, { surface: "muted", className: "aspect-video", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-40", size: "5xl", "aria-hidden": "true", children: eventTypeMeta(event.type).emoji }) })) }), _jsxs(Stack, { className: `flex-1 ${__P.p4}`, children: [_jsxs(Row, { className: "mb-2", align: "start", justify: "between", gap: "sm", children: [_jsx(EventTypeBadge, { type: event.type }), _jsx(EventStatusBadge, { status: event.status })] }), _jsx(Link, { href: detailHref, className: "block", children: _jsx(Heading, { level: 3, className: "text-[var(--appkit-color-text)] leading-snug mb-1 group-hover:text-primary transition-colors", size: "base", weight: "semibold", children: safeTitle }) }), _jsx(RichText, { html: normalizeRichTextHtml(event.description ?? ""), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mb-3 line-clamp-3 text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)]" }), _jsxs(Row, { color: "muted", textSize: "xs", className: "mb-3 mt-auto", align: "center", justify: "between", children: [event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE &&
29
+ daysLeft > 0 && _jsxs(Span, { children: ["\u23F1 ", daysLeft, "d remaining"] }), _jsxs(Span, { children: ["\u2665 ", event.stats.likeCount ?? 0, " ", (event.stats.likeCount ?? 0) === 1 ? "like" : "likes"] })] }), event.status === EVENT_FIELDS.STATUS_VALUES.ACTIVE && onParticipate ? (_jsx(Button, { rounded: "lg", type: "button", onClick: () => onParticipate(event), className: "w-full bg-primary py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-white transition-colors hover:bg-primary-600", children: labels.participate ?? "Participate" })) : (_jsxs(TextLink, { rounded: "lg", paddingX: "sm", paddingY: "xs", href: detailHref, layout: "inline-flex", align: "center", justify: "center", gap: "xs", className: "w-full border border-[var(--appkit-color-border)] transition-colors hover:bg-[var(--appkit-color-bg)]", color: "primary", size: "sm", weight: "medium", children: [labels.viewDetails ?? "View details", " \u2192"] }))] })] }));
38
30
  /*
39
31
  * 🛑 Default, not opt-in. Nothing in either repo ever passed
40
32
  * `renderOwnerActions`, so this toolbar was unreachable on every grid that
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The charts on an event page — whichever ones have real data.
3
+ *
4
+ * 🛑 THE WHOLE SECTION DISAPPEARS WHEN NOTHING QUALIFIES, and each chart
5
+ * independently returns `null` from its own builder rather than rendering an
6
+ * empty frame. A chart says "we measured this"; an axis pair with a flat line
7
+ * says it too, and says something false. An absent chart says "not yet",
8
+ * which is both true and the common case for a new event.
9
+ *
10
+ * `<Chart>` declares its own `"use client"` internally and loads recharts via
11
+ * `dynamic(..., { ssr: false })`, so this file needs no client boundary and a
12
+ * Server Component can mount it directly.
13
+ *
14
+ * @tag domain:events
15
+ * @tag layer:component
16
+ */
17
+ import React from "react";
18
+ import type { ChartSpec } from "../../../ui/components/chart-spec";
19
+ import type { SaleProgress } from "../utils/event-charts";
20
+ export interface EventInsightsProps {
21
+ pollChart?: ChartSpec | null;
22
+ savingsChart?: ChartSpec | null;
23
+ likeTrendChart?: ChartSpec | null;
24
+ saleProgress?: SaleProgress | null;
25
+ heading?: string;
26
+ }
27
+ export declare function EventInsights({ pollChart, savingsChart, likeTrendChart, saleProgress, heading, }: EventInsightsProps): React.JSX.Element | null;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Chart } from "../../../ui/components/Chart.js";
3
+ import { Grid, Heading, Progress, Row, Stack, Text } from "../../../ui/index.js";
4
+ import { CountdownDisplay } from "../../../ui/components/CountdownDisplay.js";
5
+ export function EventInsights({ pollChart, savingsChart, likeTrendChart, saleProgress, heading = "By the numbers", }) {
6
+ const charts = [savingsChart, pollChart, likeTrendChart].filter((c) => Boolean(c));
7
+ if (charts.length === 0 && !saleProgress)
8
+ return null;
9
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", color: "primary", children: heading }), saleProgress && (_jsxs(Stack, { gap: "sm", surface: "card", padding: "md", rounded: "xl", border: "default", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: saleProgress.endsAt ? "Time remaining" : "This has ended" }), saleProgress.endsAt && (_jsx(Text, { size: "sm", color: "muted", children: _jsx(CountdownDisplay, { targetDate: saleProgress.endsAt, format: "auto" }) }))] }), _jsx(Progress, { value: saleProgress.elapsedPercent, variant: saleProgress.elapsedPercent > 85 ? "warning" : "primary", size: "sm", label: "Sale window elapsed" }), saleProgress.claimedPercent !== null && (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", children: "Claimed" }), _jsx(Text, { size: "sm", color: "muted", children: saleProgress.claimedLabel })] }), _jsx(Progress, { value: saleProgress.claimedPercent, variant: saleProgress.claimedPercent > 85 ? "error" : "success", size: "sm", label: "Redemptions against the cap" })] }))] })), charts.length > 0 && (_jsx(Grid, { cols: "cardsWide", gap: "lg", children: charts.map((spec) => (_jsx(Chart, { spec: spec, height: 260 }, spec.title ?? spec.caption))) }))] }));
10
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The like toggle. Optimistic, signed-in only, and safe to double-click.
3
+ *
4
+ * @tag domain:events
5
+ * @tag layer:component
6
+ */
7
+ import React from "react";
8
+ export interface EventLikeButtonProps {
9
+ eventId: string;
10
+ /** Server-rendered starting state. */
11
+ initialLiked: boolean;
12
+ initialCount: number;
13
+ /** POSTs the toggle and resolves with the authoritative new state. */
14
+ onToggle: (eventId: string) => Promise<{
15
+ liked: boolean;
16
+ likeCount: number;
17
+ }>;
18
+ size?: "sm" | "md";
19
+ className?: string;
20
+ }
21
+ export declare function EventLikeButton({ eventId, initialLiked, initialCount, onToggle, size, className, }: EventLikeButtonProps): React.JSX.Element;
@@ -0,0 +1,59 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * The like toggle. Optimistic, signed-in only, and safe to double-click.
5
+ *
6
+ * @tag domain:events
7
+ * @tag layer:component
8
+ */
9
+ import { useCallback, useState, useTransition } from "react";
10
+ import { Button, Row, Span, Icon } from "../../../ui/index.js";
11
+ import { PressScale } from "../../../ui/components/Motion.js";
12
+ import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
13
+ import { LoginRequiredModal } from "../../../ui/components/LoginRequiredModal.js";
14
+ import { normalizeError } from "../../../errors/normalize.js";
15
+ export function EventLikeButton({ eventId, initialLiked, initialCount, onToggle, size = "md", className = "", }) {
16
+ const session = useOptionalSession();
17
+ const [liked, setLiked] = useState(initialLiked);
18
+ const [count, setCount] = useState(initialCount);
19
+ const [showLogin, setShowLogin] = useState(false);
20
+ const [pending, startTransition] = useTransition();
21
+ const handleClick = useCallback(() => {
22
+ /*
23
+ * 🛑 THREE STATES, NOT TWO. `SessionProvider` mounts `initialUser={null}`
24
+ * because the root locale layout must stay static (Root Cause #82), so
25
+ * `loading` is true on every hard load FOR SIGNED-IN USERS TOO. Testing
26
+ * only `!user` would open the sign-in modal at a buyer who is already
27
+ * signed in, on every first click after a page load — the exact defect
28
+ * Root Cause #79 records for the wishlist heart.
29
+ */
30
+ if (session?.loading)
31
+ return;
32
+ if (!session?.user?.uid) {
33
+ setShowLogin(true);
34
+ return;
35
+ }
36
+ // Optimistic: flip now, reconcile from the server response. A like is
37
+ // cheap to be briefly wrong about and the latency is what people notice.
38
+ const nextLiked = !liked;
39
+ setLiked(nextLiked);
40
+ setCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
41
+ startTransition(() => {
42
+ void onToggle(eventId)
43
+ .then((res) => {
44
+ // The server is authoritative — it holds the real count, which may
45
+ // have moved for other reasons between paint and click.
46
+ setLiked(res.liked);
47
+ setCount(res.likeCount);
48
+ })
49
+ .catch((err) => {
50
+ void normalizeError(err);
51
+ // Roll back rather than leaving a lie on screen.
52
+ setLiked(liked);
53
+ setCount(initialCount);
54
+ });
55
+ });
56
+ }, [session, liked, initialCount, eventId, onToggle]);
57
+ const label = liked ? "Liked" : "Like";
58
+ return (_jsxs(_Fragment, { children: [_jsx(PressScale, { children: _jsx(Button, { type: "button", variant: liked ? "primary" : "outline", size: size, onClick: handleClick, isLoading: pending, "aria-pressed": liked, "aria-label": `${label} this event — ${count} ${count === 1 ? "person likes" : "people like"} it`, className: className, children: _jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Icon, { name: "wishlist", filled: liked }), _jsx(Span, { weight: "medium", children: label }), _jsx(Span, { numeric: true, children: count })] }) }) }), _jsx(LoginRequiredModal, { isOpen: showLogin, onClose: () => setShowLogin(false), message: "Sign in to like this event and see who else has." })] }));
59
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * "Liked by" — the last ten faces, then a way to see the rest.
3
+ *
4
+ * @tag domain:events
5
+ * @tag layer:component
6
+ */
7
+ import React from "react";
8
+ import type { PublicLiker } from "../schemas/likes";
9
+ export interface EventLikersRailProps {
10
+ /** The most recent likers, server-rendered. */
11
+ likers: PublicLiker[];
12
+ /** The event's total like count — usually larger than `likers.length`. */
13
+ totalCount: number;
14
+ /** True when more exist past the preview. */
15
+ hasMore: boolean;
16
+ /** Fetches one more page for the modal. */
17
+ loadMore?: (offset: number) => Promise<{
18
+ likers: PublicLiker[];
19
+ hasMore: boolean;
20
+ }>;
21
+ className?: string;
22
+ }
23
+ export declare function EventLikersRail({ likers, totalCount, hasMore, loadMore, className, }: EventLikersRailProps): React.JSX.Element | null;
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * "Liked by" — the last ten faces, then a way to see the rest.
5
+ *
6
+ * @tag domain:events
7
+ * @tag layer:component
8
+ */
9
+ import { useCallback, useState } from "react";
10
+ import { AvatarGroup, Button, Modal, Row, Stack, Text, Span, Avatar } from "../../../ui/index.js";
11
+ import { LIKERS_PAGE_SIZE } from "../schemas/likes.js";
12
+ import { normalizeError } from "../../../errors/normalize.js";
13
+ /** "Aarav, Riya and 7 others" — names first, count only for the remainder. */
14
+ function summarise(likers, total) {
15
+ if (likers.length === 0)
16
+ return "";
17
+ const shown = likers.slice(0, 2).map((l) => l.displayName);
18
+ const others = Math.max(0, total - shown.length);
19
+ if (others === 0)
20
+ return shown.join(" and ");
21
+ return `${shown.join(", ")} and ${others} other${others === 1 ? "" : "s"}`;
22
+ }
23
+ export function EventLikersRail({ likers, totalCount, hasMore, loadMore, className = "", }) {
24
+ const [open, setOpen] = useState(false);
25
+ const [all, setAll] = useState(likers);
26
+ const [more, setMore] = useState(hasMore);
27
+ const [loading, setLoading] = useState(false);
28
+ const handleLoadMore = useCallback(() => {
29
+ if (!loadMore || loading)
30
+ return;
31
+ setLoading(true);
32
+ void loadMore(all.length)
33
+ .then((page) => {
34
+ setAll((prev) => [...prev, ...page.likers]);
35
+ setMore(page.hasMore);
36
+ })
37
+ .catch((err) => {
38
+ void normalizeError(err);
39
+ // Stop offering a button that is not working, rather than letting it
40
+ // sit there doing nothing on every press.
41
+ setMore(false);
42
+ })
43
+ .finally(() => setLoading(false));
44
+ }, [loadMore, loading, all.length]);
45
+ /*
46
+ * Nothing to show. Deliberately renders null rather than "Be the first to
47
+ * like this" — an empty social proof row advertises that nobody has, which
48
+ * is worse for the event than saying nothing at all. The like BUTTON is
49
+ * still there; this is only the rail beside it.
50
+ */
51
+ if (likers.length === 0)
52
+ return null;
53
+ return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", gap: "sm", className: className, children: [_jsx(AvatarGroup, { size: "sm", max: likers.length, avatars: likers.map((l) => ({ src: l.photoURL, name: l.displayName, alt: l.displayName })) }), _jsxs(Text, { size: "sm", color: "muted", children: ["Liked by ", _jsx(Span, { weight: "medium", children: summarise(likers, totalCount) })] }), (hasMore || totalCount > likers.length) && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setOpen(true), children: "View all" }))] }), _jsx(Modal, { isOpen: open, onClose: () => setOpen(false), title: `Liked by ${totalCount}`, children: _jsxs(Stack, { gap: "sm", children: [all.map((l) => (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Avatar, { size: "sm", src: l.photoURL, name: l.displayName, alt: l.displayName }), _jsx(Text, { size: "sm", children: l.displayName })] }, l.uid))), more && loadMore && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: handleLoadMore, isLoading: loading, children: ["Show ", LIKERS_PAGE_SIZE, " more"] }))] }) })] }));
54
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The event-type chip. Emoji plus a real label, always both.
3
+ *
4
+ * 🛑 THE LABEL IS NOT OPTIONAL, and that is the accessibility half of this
5
+ * component. `EventCard` rendered the type as a bare `aria-hidden` emoji with
6
+ * no text companion anywhere on the card, so a screen-reader user got no
7
+ * event type at all; the detail hero rendered the raw enum. Neither surface
8
+ * could reach the other's map. This is the shared one.
9
+ *
10
+ * @tag domain:events
11
+ * @tag layer:component
12
+ */
13
+ import React from "react";
14
+ export interface EventTypeBadgeProps {
15
+ type: string | undefined;
16
+ /**
17
+ * Hide the text and keep only the glyph — for a dense card corner where the
18
+ * label is already rendered nearby.
19
+ *
20
+ * 🛑 Even then an `aria-label` carries the real label, so the type is never
21
+ * lost to a screen reader. A silent emoji is not a badge.
22
+ */
23
+ compact?: boolean;
24
+ className?: string;
25
+ }
26
+ export declare function EventTypeBadge({ type, compact, className }: EventTypeBadgeProps): React.JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Span } from "../../../ui/index.js";
3
+ import { eventTypeMeta } from "../constants/event-type-meta.js";
4
+ export function EventTypeBadge({ type, compact = false, className = "" }) {
5
+ const meta = eventTypeMeta(type);
6
+ return (_jsxs(Span, { layout: "inline-flex", align: "center", gap: "xs", padding: "pill-sm", rounded: "full", size: "xs", weight: "semibold", className: `${meta.chipClass} ${className}`, "aria-label": compact ? meta.label : undefined, children: [_jsx(Span, { "aria-hidden": "true", children: meta.emoji }), !compact && meta.label] }));
7
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { eventRepository } from "../../../repositories/index.js";
3
3
  import { AdSlot } from "../../homepage/components/AdSlot.js";
4
- import { Container, Heading, Main, Section } from "../../../ui/index.js";
4
+ import { Container, Main, Section, PageHeroHeading } from "../../../ui/index.js";
5
5
  import { EventsIndexListing } from "./EventsIndexListing.js";
6
6
  import { EVENT_FIELDS } from "../../../constants/field-names.js";
7
7
  import { sieveFilter, sieveMultiEq, sieveAnd, SIEVE_OP } from "../../../utils/sieve-builder.js";
@@ -72,5 +72,5 @@ export async function EventsListPageView({ searchParams = {} }) {
72
72
  const publicResult = result
73
73
  ? { ...result, items: sortPinnedFirst(hidePublicTestData(result.items)) }
74
74
  : result;
75
- 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: "Events" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
75
+ return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(PageHeroHeading, { className: "mb-8", pill: "Events & Offers", title: "Events", description: "Sales, spin wheels, raffles and polls \u2014 everything running right now, and what is opening next.", meta: publicResult?.total ? `${publicResult.total} running` : undefined }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(EventsIndexListing, { initialData: publicResult, facetCounts: facetCounts }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
76
76
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The wheel itself — segments, a pointer, and the settle.
3
+ *
4
+ * ## What this replaced
5
+ *
6
+ * A CSS circle with one 🎁 emoji rotating 2160deg on a fixed 3.2s timer. No
7
+ * segments, no pointer, and the prize list was loaded but used ONLY to decide
8
+ * whether to disable the button — so a visitor could not see what they might
9
+ * win. `prefers-reduced-motion` was ignored entirely, while every other
10
+ * primitive in `Motion.tsx` wires it.
11
+ *
12
+ * ## 🛑 SEGMENTS ARE EQUAL-SIZED, AND THAT IS NOT A SHORTCUT
13
+ *
14
+ * `weight` IS the odds. `toClientSpinPrize` is an allow-list emitting
15
+ * `{id, label, isActive}` only, and a tester case asserts
16
+ * `weightsInPublicSource: 0` — the stored shape leaked every wheel's odds into
17
+ * a client prop once already (Root Cause #70). Sizing a segment by its weight
18
+ * would publish the same information geometrically instead of as a number.
19
+ *
20
+ * So the wheel shows WHAT you could win, never how likely it is. That is also
21
+ * the honest framing: these are the prizes.
22
+ *
23
+ * @tag domain:events
24
+ * @tag layer:component
25
+ */
26
+ import React from "react";
27
+ export interface SpinWheelSegment {
28
+ id: string;
29
+ label: string;
30
+ }
31
+ export interface SpinWheelProps {
32
+ segments: SpinWheelSegment[];
33
+ /** Index of the winning segment once known, else null. */
34
+ winningIndex: number | null;
35
+ /** True from the click until the result is revealed. */
36
+ spinning: boolean;
37
+ /** Fired when the wheel has physically stopped. */
38
+ onSettled?: () => void;
39
+ size?: number;
40
+ }
41
+ export declare function SpinWheel({ segments, winningIndex, spinning, onSettled, size, }: SpinWheelProps): React.JSX.Element;