@mohasinac/appkit 4.63.5 → 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 (117) 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/events/like-actions.d.ts +25 -0
  5. package/dist/_internal/server/features/events/like-actions.js +67 -0
  6. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  7. package/dist/_internal/server/features/events/likes.js +146 -0
  8. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  9. package/dist/_internal/server/features/seo/index.js +2 -0
  10. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  11. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  12. package/dist/_internal/server/features/seo/robots.js +20 -1
  13. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  14. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  15. package/dist/_internal/server/features/seo/static-params.js +228 -0
  16. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  17. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  18. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  19. package/dist/_internal/server/features/stores/adapters.js +87 -1
  20. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  21. package/dist/_internal/server/functions/scheduled.js +23 -1
  22. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  23. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  24. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  25. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  26. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  27. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  28. package/dist/_internal/shared/actions/action-registry.js +61 -0
  29. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  30. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  31. package/dist/constants/api-endpoints.d.ts +24 -0
  32. package/dist/constants/api-endpoints.js +8 -0
  33. package/dist/features/about/components/HelpPageView.js +9 -1
  34. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  35. package/dist/features/account/components/BecomeSellerView.js +2 -2
  36. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  37. package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
  38. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  39. package/dist/features/admin/components/AdminStoresView.js +59 -2
  40. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  41. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  42. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  43. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  44. package/dist/features/blog/components/BlogListView.js +2 -1
  45. package/dist/features/blog/components/BlogPostView.js +2 -1
  46. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  47. package/dist/features/blog/constants/category-badge.js +40 -0
  48. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  49. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  50. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  51. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  52. package/dist/features/categories/utils/load-category-options.js +50 -0
  53. package/dist/features/events/components/EventCard.js +4 -12
  54. package/dist/features/events/components/EventInsights.d.ts +27 -0
  55. package/dist/features/events/components/EventInsights.js +10 -0
  56. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  57. package/dist/features/events/components/EventLikeButton.js +59 -0
  58. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  59. package/dist/features/events/components/EventLikersRail.js +54 -0
  60. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  61. package/dist/features/events/components/EventTypeBadge.js +7 -0
  62. package/dist/features/events/components/EventsListPageView.js +2 -2
  63. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  64. package/dist/features/events/components/SpinWheel.js +128 -0
  65. package/dist/features/events/components/SpinWheelView.js +36 -15
  66. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  67. package/dist/features/events/constants/event-type-meta.js +109 -0
  68. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  69. package/dist/features/events/repository/event-likes.repository.js +151 -0
  70. package/dist/features/events/schemas/firestore.d.ts +111 -0
  71. package/dist/features/events/schemas/likes.d.ts +81 -0
  72. package/dist/features/events/schemas/likes.js +59 -0
  73. package/dist/features/events/types/index.d.ts +7 -0
  74. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  75. package/dist/features/events/utils/carousel-slide.js +174 -0
  76. package/dist/features/events/utils/event-charts.d.ts +65 -0
  77. package/dist/features/events/utils/event-charts.js +172 -0
  78. package/dist/features/faq/constants/help-guides.js +9 -0
  79. package/dist/features/faq/schemas/firestore.js +1 -0
  80. package/dist/features/faq/types/index.d.ts +19 -0
  81. package/dist/features/homepage/components/BrandsSection.js +25 -1
  82. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  83. package/dist/features/homepage/types/index.d.ts +7 -0
  84. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  85. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  86. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  87. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  88. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  89. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
  90. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  91. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  92. package/dist/features/stores/components/StoreGuideHubView.js +17 -4
  93. package/dist/features/stores/components/StoreHeader.js +2 -1
  94. package/dist/features/stores/repository/store.repository.js +23 -0
  95. package/dist/features/stores/schemas/firestore.d.ts +60 -1
  96. package/dist/features/stores/schemas/firestore.js +18 -0
  97. package/dist/features/stores/schemas/index.d.ts +2 -2
  98. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  99. package/dist/features/stores/schemas/store-create.js +86 -0
  100. package/dist/features/stores/types/index.d.ts +42 -0
  101. package/dist/next/routing/route-map.d.ts +3 -1
  102. package/dist/next/routing/route-map.js +13 -0
  103. package/dist/schemas/registry.d.ts +10 -0
  104. package/dist/server.d.ts +5 -0
  105. package/dist/server.js +10 -0
  106. package/dist/styles.css +36 -1
  107. package/dist/tailwind-utilities.css +1 -1
  108. package/dist/ui/components/HorizontalScroller.js +15 -2
  109. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  110. package/dist/ui/components/PageHeroHeading.js +15 -0
  111. package/dist/ui/components/Typography.d.ts +3 -3
  112. package/dist/ui/components/Typography.js +59 -31
  113. package/dist/ui/components/Typography.style.css +35 -0
  114. package/dist/ui/index.d.ts +2 -0
  115. package/dist/ui/index.js +1 -0
  116. package/package.json +1 -1
  117. package/scripts/seed-cli.mjs +4 -2
@@ -9,6 +9,78 @@ import type { LotteryConfig } from "../../lottery/types";
9
9
  import type { Encrypted, Plaintext } from "../../../security/encrypted";
10
10
  export declare const EVENTS_COLLECTION: "events";
11
11
  export declare const EVENT_ENTRIES_COLLECTION: "eventEntries";
12
+ /**
13
+ * Shared by both placements: which slide this event owns, and how prominent.
14
+ *
15
+ * `order` is the slide's position in the hero rail. `active` is what decides
16
+ * whether it renders at all — and it is capped downstream, not here: the rail
17
+ * shows at most `MAX_ACTIVE_SLIDES` (5), and `buildSlideFromEvent` refuses
18
+ * loudly rather than emitting a sixth that would never be seen.
19
+ */
20
+ interface EventCarouselBase {
21
+ /** Slide order in the hero rail. Lower is earlier. */
22
+ order?: number;
23
+ /** Whether the generated slide is live. Absent means false — opt in. */
24
+ active?: boolean;
25
+ /**
26
+ * Background image slug for the slide. Defaults to the event's own
27
+ * `coverImage` when absent, which is usually what you want: the slide and
28
+ * the page it links to then read as one promotion.
29
+ *
30
+ * 🛑 A cover is cropped at `banner` (1200x600) and a hero background wants
31
+ * `hero` (1920x860). Naming a dedicated hero slug here is the difference
32
+ * between a sharp full-bleed backdrop and a 1200px image upscaled across it.
33
+ */
34
+ backgroundMediaSlug?: string;
35
+ /** Dim applied over the background so inverse ink stays readable. 0–1. */
36
+ dimOpacity?: number;
37
+ }
38
+ /**
39
+ * One headline, one line of copy, one button, centred over the background.
40
+ * The right shape for a single offer with a single call to action.
41
+ */
42
+ export interface EventCarouselOverlayConfig extends EventCarouselBase {
43
+ placement: "overlay";
44
+ /** Small uppercase line above the headline — a date, a status, a hook. */
45
+ eyebrow?: string;
46
+ /** The headline. Kept short: it renders at up to 6rem on a wide screen. */
47
+ title: string;
48
+ description?: string;
49
+ buttonText?: string;
50
+ }
51
+ /**
52
+ * Up to six independently-linked tiles in a 2x3 grid.
53
+ *
54
+ * This is what makes one slide able to advertise several deals at once —
55
+ * each card is its own background, its own copy and its own destination, so
56
+ * three sale events can share a single slide instead of consuming three of
57
+ * the five available.
58
+ */
59
+ export interface EventCarouselCardsConfig extends EventCarouselBase {
60
+ placement: "cards";
61
+ /**
62
+ * 🛑 Max SIX. Zones are 1–6 over a 2-row x 3-column grid (row 1 = 1·2·3,
63
+ * row 2 = 4·5·6) and `HeroCarousel` slices to 6 — a seventh card is
64
+ * silently dropped, so it is refused at build time instead.
65
+ */
66
+ cards: EventCarouselCard[];
67
+ }
68
+ /** One tile. Mirrors `CarouselCard` but without the stored-slide plumbing. */
69
+ export interface EventCarouselCard {
70
+ /** 1–3 is the top row, 4–6 the bottom. Desktop only — a phone lays these
71
+ * out in document order, one per row. */
72
+ zone: 1 | 2 | 3 | 4 | 5 | 6;
73
+ /** Background image slug for this tile. */
74
+ mediaSlug?: string;
75
+ dimOpacity?: number;
76
+ eyebrow?: string;
77
+ title?: string;
78
+ description?: string;
79
+ /** Where the tile's button goes. Absent uses the event's own detail page. */
80
+ href?: string;
81
+ buttonText?: string;
82
+ }
83
+ export type EventCarouselConfig = EventCarouselOverlayConfig | EventCarouselCardsConfig;
12
84
  export interface EventDocument extends BaseDocument {
13
85
  /** Normalized edge-n-gram search field — see appkit/src/utils/search-txt.ts. */
14
86
  searchTxt?: string[];
@@ -63,6 +135,28 @@ export interface EventDocument extends BaseDocument {
63
135
  * renderer, on purpose.
64
136
  */
65
137
  couponIds?: string[];
138
+ /**
139
+ * How this event presents itself as a homepage carousel slide.
140
+ *
141
+ * An event is the natural owner of its own promo artwork: authoring the
142
+ * slide here rather than in a separate `carouselSlides` document means the
143
+ * two cannot drift, which they did — the live Diwali slide and its event
144
+ * page carried different headline copy for weeks because each was edited
145
+ * separately.
146
+ *
147
+ * `buildSlideFromEvent()` is the one function that turns this into a
148
+ * `CarouselSlideDocument`; both the seed and `push-editorial-content.mjs`
149
+ * call it, so there is no second rendering of the same intent.
150
+ *
151
+ * 🛑 A DISCRIMINATED UNION ON `placement`, NOT TWO OPTIONAL ARRAYS. A slide's
152
+ * overlay and its cards are sibling `POSITION_FILL` elements — they stack on
153
+ * exactly the same pixels, so a slide carrying both renders its text twice,
154
+ * overlapping. Making that unrepresentable is why this is a union;
155
+ * `push-editorial-content.mjs` had to enforce the same rule imperatively by
156
+ * nulling the opposite field on every merge-write, which only works for as
157
+ * long as everyone remembers to do it.
158
+ */
159
+ carouselConfig?: EventCarouselConfig;
66
160
  saleConfig?: SaleConfig;
67
161
  offerConfig?: OfferConfig;
68
162
  pollConfig?: PollConfig;
@@ -131,6 +225,22 @@ export interface EventDocument extends BaseDocument {
131
225
  totalEntries: number;
132
226
  approvedEntries: number;
133
227
  flaggedEntries: number;
228
+ /**
229
+ * Likes. The public social signal, shown on the card and the detail hero
230
+ * in place of the entry count — nobody "enters" a cashback code, so
231
+ * `totalEntries` was structurally 0 on every `offer` and `sale` event
232
+ * while still being rendered as "0 entries".
233
+ *
234
+ * 🛑 OPTIONAL, and readers must treat absent as 0. Every event written
235
+ * before this field existed has no value for it, so a Firestore
236
+ * `orderBy("stats.likeCount")` would EXCLUDE all of them (Root Cause
237
+ * #100) — sort likes in memory, never in the query.
238
+ *
239
+ * Written only by `eventLikesRepository.toggle`, inside the same
240
+ * transaction that creates or deletes the like row, so the count and the
241
+ * membership cannot disagree.
242
+ */
243
+ likeCount?: number;
134
244
  };
135
245
  createdBy: string;
136
246
  }
@@ -292,3 +402,4 @@ export declare const eventQueryHelpers: {
292
402
  readonly withRaffle: () => readonly ["hasRaffle", "==", true];
293
403
  readonly raffleNotYetDrawn: () => readonly ["raffleWinnerUserId", "==", null];
294
404
  };
405
+ export {};
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Event likes — the public "N people like this" signal, and who.
3
+ *
4
+ * ## Why this replaced "participants" on public surfaces
5
+ *
6
+ * Every event card and the detail hero showed `stats.totalEntries` as
7
+ * "N entries". For a raffle or a poll an entry is a real thing; for an
8
+ * `offer` or a `sale` nobody "enters" a cashback code, so the number was
9
+ * structurally 0 and the label meant nothing. A like works on every event
10
+ * type, which an entry cannot.
11
+ *
12
+ * Entries are NOT replaced anywhere they are real — participate, leaderboard
13
+ * and every admin surface still read `eventEntries`. This is a second,
14
+ * lighter signal beside them, not a rename of the first.
15
+ *
16
+ * ## 🛑 The document id IS the idempotency key
17
+ *
18
+ * `{eventId}__{userId}`, the `courierVoteId` precedent. A like is a `.set()`,
19
+ * an unlike is a `.delete()`, and "have I liked this" is a single
20
+ * `.doc(id).get()` — no query, no index, no transaction needed to dedupe.
21
+ *
22
+ * The alternative considered and rejected was the review-helpful shape, an
23
+ * `likedBy[]` array on the event document. It has no un-vote by design (see
24
+ * `voteHelpful`'s own comment) and it caps out at Firestore's 1 MiB document
25
+ * limit, which is a real ceiling for a popular event and a silent one.
26
+ *
27
+ * ## 🛑 No denormalised name or email on this row
28
+ *
29
+ * Only `userId`. `enterEvent` denormalises `userDisplayName`/`userEmail` onto
30
+ * an entry and both are then PII-encrypted, masked on read, and must be
31
+ * stripped by every projection that touches them. A likes row is read on a
32
+ * PUBLIC surface by definition, so the cheapest way to never leak a field is
33
+ * to not store it: the display name and avatar are joined from the user's own
34
+ * public projection at read time instead.
35
+ *
36
+ * @tag domain:events
37
+ * @tag layer:schema
38
+ */
39
+ import type { BaseDocument } from "../../../_internal/shared/types/base-document";
40
+ export declare const EVENT_LIKES_COLLECTION: "eventLikes";
41
+ /**
42
+ * The deterministic id. A repeat like overwrites itself rather than
43
+ * double-counting; an unlike deletes exactly this document.
44
+ *
45
+ * 🛑 `__` matches `courierVoteId` and the tester-response id
46
+ * (`${testerId}__${checklistItemId}`). Firestore ids may not contain `/`, and
47
+ * a single `_` appears inside real uids — a double underscore does not.
48
+ */
49
+ export declare function eventLikeId(eventId: string, userId: string): string;
50
+ export interface EventLikeDocument extends BaseDocument {
51
+ eventId: string;
52
+ /** The Firebase Auth uid. The ONLY identity stored — see the header. */
53
+ userId: string;
54
+ createdAt: Date;
55
+ }
56
+ export declare const EVENT_LIKE_FIELDS: {
57
+ readonly EVENT_ID: "eventId";
58
+ readonly USER_ID: "userId";
59
+ readonly CREATED_AT: "createdAt";
60
+ };
61
+ /**
62
+ * A liker as a public visitor may see them.
63
+ *
64
+ * An explicit allow-list, never a spread of the user document: that document
65
+ * carries email, phone, role, addresses and store ownership, and a spread
66
+ * publishes every field nobody thought to delete — including fields the
67
+ * interface does not declare (Root Cause #70).
68
+ *
69
+ * `displayName` and `photoURL` are both in `USER_PUBLIC_FIELDS` and neither is
70
+ * in `USER_PII_FIELDS`; they already render on public profile pages, so this
71
+ * publishes nothing new.
72
+ */
73
+ export interface PublicLiker {
74
+ uid: string;
75
+ displayName: string;
76
+ photoURL?: string;
77
+ }
78
+ /** How many faces the rail shows before "View all". */
79
+ export declare const LIKERS_PREVIEW_LIMIT = 10;
80
+ /** One page of the "everyone who liked this" modal. */
81
+ export declare const LIKERS_PAGE_SIZE = 24;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Event likes — the public "N people like this" signal, and who.
3
+ *
4
+ * ## Why this replaced "participants" on public surfaces
5
+ *
6
+ * Every event card and the detail hero showed `stats.totalEntries` as
7
+ * "N entries". For a raffle or a poll an entry is a real thing; for an
8
+ * `offer` or a `sale` nobody "enters" a cashback code, so the number was
9
+ * structurally 0 and the label meant nothing. A like works on every event
10
+ * type, which an entry cannot.
11
+ *
12
+ * Entries are NOT replaced anywhere they are real — participate, leaderboard
13
+ * and every admin surface still read `eventEntries`. This is a second,
14
+ * lighter signal beside them, not a rename of the first.
15
+ *
16
+ * ## 🛑 The document id IS the idempotency key
17
+ *
18
+ * `{eventId}__{userId}`, the `courierVoteId` precedent. A like is a `.set()`,
19
+ * an unlike is a `.delete()`, and "have I liked this" is a single
20
+ * `.doc(id).get()` — no query, no index, no transaction needed to dedupe.
21
+ *
22
+ * The alternative considered and rejected was the review-helpful shape, an
23
+ * `likedBy[]` array on the event document. It has no un-vote by design (see
24
+ * `voteHelpful`'s own comment) and it caps out at Firestore's 1 MiB document
25
+ * limit, which is a real ceiling for a popular event and a silent one.
26
+ *
27
+ * ## 🛑 No denormalised name or email on this row
28
+ *
29
+ * Only `userId`. `enterEvent` denormalises `userDisplayName`/`userEmail` onto
30
+ * an entry and both are then PII-encrypted, masked on read, and must be
31
+ * stripped by every projection that touches them. A likes row is read on a
32
+ * PUBLIC surface by definition, so the cheapest way to never leak a field is
33
+ * to not store it: the display name and avatar are joined from the user's own
34
+ * public projection at read time instead.
35
+ *
36
+ * @tag domain:events
37
+ * @tag layer:schema
38
+ */
39
+ export const EVENT_LIKES_COLLECTION = "eventLikes";
40
+ /**
41
+ * The deterministic id. A repeat like overwrites itself rather than
42
+ * double-counting; an unlike deletes exactly this document.
43
+ *
44
+ * 🛑 `__` matches `courierVoteId` and the tester-response id
45
+ * (`${testerId}__${checklistItemId}`). Firestore ids may not contain `/`, and
46
+ * a single `_` appears inside real uids — a double underscore does not.
47
+ */
48
+ export function eventLikeId(eventId, userId) {
49
+ return `${eventId}__${userId}`;
50
+ }
51
+ export const EVENT_LIKE_FIELDS = {
52
+ EVENT_ID: "eventId",
53
+ USER_ID: "userId",
54
+ CREATED_AT: "createdAt",
55
+ };
56
+ /** How many faces the rail shows before "View all". */
57
+ export const LIKERS_PREVIEW_LIMIT = 10;
58
+ /** One page of the "everyone who liked this" modal. */
59
+ export const LIKERS_PAGE_SIZE = 24;
@@ -148,6 +148,13 @@ export interface EventItem {
148
148
  totalEntries: number;
149
149
  approvedEntries: number;
150
150
  flaggedEntries: number;
151
+ /**
152
+ * Likes — the public social signal the card and hero render in place of
153
+ * the entry count. OPTIONAL: every event written before the field has
154
+ * no value, so readers must treat absent as 0 and must never sort on it
155
+ * in Firestore (an orderBy excludes documents lacking the field).
156
+ */
157
+ likeCount?: number;
151
158
  };
152
159
  createdBy: string;
153
160
  createdAt: string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Turn an event's own `carouselConfig` into a hero-carousel slide.
3
+ *
4
+ * WHY THIS EXISTS: the homepage slide advertising an event and the event page
5
+ * it links to were authored in two different places — a `carouselSlides`
6
+ * document and an `events` document — with nothing tying them together. They
7
+ * drifted. The live Diwali slide read "Diwali: 10% cashback on every order"
8
+ * while its event page had been rebranded to "Diwali to New Year: Four Offers,
9
+ * Three Months", and neither edit knew about the other.
10
+ *
11
+ * So the event owns its promo, and this is the only function that renders that
12
+ * intent into a slide. The seed derives `carouselSlidesSeedData` through it;
13
+ * `scripts/push-editorial-content.mjs` pushes what the seed produced. Neither
14
+ * hand-writes a slide, so there is no second copy to drift from.
15
+ */
16
+ import type { CarouselSlideDocument } from "../../homepage/schemas/firestore";
17
+ import type { EventDocument } from "../schemas/firestore";
18
+ export interface BuildSlideOptions {
19
+ /** The event's public detail path, e.g. `/events/festive-cashback-10`. */
20
+ eventHref: string;
21
+ /** Who the generated document records as its author. */
22
+ createdBy: string;
23
+ }
24
+ /**
25
+ * `null` when the event carries no `carouselConfig` — most events do not want
26
+ * a slide, and that is the normal case rather than an error.
27
+ *
28
+ * @throws when the config is internally impossible (more cards than zones).
29
+ * Refusing loudly is deliberate: `HeroCarousel` silently `.slice(0, 6)`s,
30
+ * so a seventh card would vanish with nothing to indicate it had been
31
+ * authored at all.
32
+ */
33
+ export declare function buildSlideFromEvent(event: EventDocument, opts: BuildSlideOptions): Partial<CarouselSlideDocument> | null;
34
+ /**
35
+ * Build every event-owned slide, newest-ordered, and refuse an over-full rail.
36
+ *
37
+ * 🛑 `MAX_ACTIVE_SLIDES` is a real ceiling, not advice — `HeroCarousel` renders
38
+ * the first five and a sixth active slide is authored, stored, and never seen
39
+ * by anyone. Failing here is what makes that visible at seed time rather than
40
+ * as a silently missing promotion.
41
+ */
42
+ export declare function buildSlidesFromEvents(events: readonly EventDocument[], resolveHref: (event: EventDocument) => string, createdBy: string): Partial<CarouselSlideDocument>[];
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Turn an event's own `carouselConfig` into a hero-carousel slide.
3
+ *
4
+ * WHY THIS EXISTS: the homepage slide advertising an event and the event page
5
+ * it links to were authored in two different places — a `carouselSlides`
6
+ * document and an `events` document — with nothing tying them together. They
7
+ * drifted. The live Diwali slide read "Diwali: 10% cashback on every order"
8
+ * while its event page had been rebranded to "Diwali to New Year: Four Offers,
9
+ * Three Months", and neither edit knew about the other.
10
+ *
11
+ * So the event owns its promo, and this is the only function that renders that
12
+ * intent into a slide. The seed derives `carouselSlidesSeedData` through it;
13
+ * `scripts/push-editorial-content.mjs` pushes what the seed produced. Neither
14
+ * hand-writes a slide, so there is no second copy to drift from.
15
+ */
16
+ import { MAX_ACTIVE_SLIDES } from "../../homepage/schemas/firestore.js";
17
+ /** Matches the seeded slides; a slower rail is unreadable, a faster one is a blur. */
18
+ const DEFAULT_AUTOPLAY_MS = 6000;
19
+ const DEFAULT_DIM = 0.55;
20
+ /** Zones are a 2-row x 3-column grid, so six is the ceiling. */
21
+ const MAX_CARDS_PER_SLIDE = 6;
22
+ /**
23
+ * `/media/<shortId>`, never `seedExtMedia()` and never a raw upstream URL.
24
+ *
25
+ * Root Cause #104: persisting `/api/media/ext?url=<third party>` makes every
26
+ * render a Node lambda plus an upstream fetch plus a sharp re-encode. ~400 of
27
+ * those contributed 52.52 GB of Fast Origin Transfer against a 10 GB allowance
28
+ * and suspended the project.
29
+ */
30
+ function mediaUrl(slug) {
31
+ return `/media/${slug}`;
32
+ }
33
+ function background(slug, dim) {
34
+ if (!slug)
35
+ return undefined;
36
+ return {
37
+ type: "image",
38
+ url: mediaUrl(slug),
39
+ dimOverlay: { enabled: true, opacity: dim },
40
+ };
41
+ }
42
+ /**
43
+ * The background slug, preferring an explicitly-named hero crop.
44
+ *
45
+ * 🛑 Falling back to the event's `coverImage` is correct but lossy: a cover is
46
+ * fetched at `banner` (1200x600) while a hero background wants `hero`
47
+ * (1920x860), so the fallback upscales across a full-bleed slide. That is
48
+ * exactly what the live Diwali slide did. Name a hero slug when the slide
49
+ * matters; the fallback is there so a slide is never blank.
50
+ */
51
+ function resolveBackgroundSlug(event) {
52
+ const cfg = event.carouselConfig;
53
+ if (cfg?.backgroundMediaSlug)
54
+ return cfg.backgroundMediaSlug;
55
+ const coverUrl = event.coverImage?.url ?? event.coverImageUrl;
56
+ if (!coverUrl)
57
+ return undefined;
58
+ // Already a `/media/<slug>` URL — recover the slug so we re-wrap it once.
59
+ const match = /^\/media\/([^/?#]+)$/.exec(coverUrl);
60
+ return match ? match[1] : undefined;
61
+ }
62
+ function toCard(card, eventHref, dim) {
63
+ return {
64
+ id: `card-zone-${card.zone}`,
65
+ zone: card.zone,
66
+ background: background(card.mediaSlug, card.dimOpacity ?? dim) ?? {
67
+ type: "color",
68
+ color: "var(--appkit-color-primary)",
69
+ },
70
+ content: {
71
+ eyebrow: card.eyebrow,
72
+ title: card.title,
73
+ description: card.description,
74
+ },
75
+ buttons: card.buttonText
76
+ ? [
77
+ {
78
+ id: `card-zone-${card.zone}-cta`,
79
+ text: card.buttonText,
80
+ href: card.href ?? eventHref,
81
+ variant: "primary",
82
+ },
83
+ ]
84
+ : [],
85
+ hover: { effect: "scale" },
86
+ };
87
+ }
88
+ /**
89
+ * `null` when the event carries no `carouselConfig` — most events do not want
90
+ * a slide, and that is the normal case rather than an error.
91
+ *
92
+ * @throws when the config is internally impossible (more cards than zones).
93
+ * Refusing loudly is deliberate: `HeroCarousel` silently `.slice(0, 6)`s,
94
+ * so a seventh card would vanish with nothing to indicate it had been
95
+ * authored at all.
96
+ */
97
+ export function buildSlideFromEvent(event, opts) {
98
+ const cfg = event.carouselConfig;
99
+ if (!cfg)
100
+ return null;
101
+ const dim = cfg.dimOpacity ?? DEFAULT_DIM;
102
+ const bg = background(resolveBackgroundSlug(event), dim);
103
+ const base = {
104
+ id: `slide-${event.id}`,
105
+ title: event.title,
106
+ order: cfg.order ?? 99,
107
+ active: cfg.active === true,
108
+ background: bg,
109
+ settings: { autoplayDelayMs: DEFAULT_AUTOPLAY_MS, height: "medium" },
110
+ createdBy: opts.createdBy,
111
+ };
112
+ if (cfg.placement === "cards") {
113
+ if (cfg.cards.length > MAX_CARDS_PER_SLIDE) {
114
+ throw new Error(`Event ${event.id}: ${cfg.cards.length} carousel cards, max is ${MAX_CARDS_PER_SLIDE} ` +
115
+ `(zones are a 2x3 grid). HeroCarousel slices to 6, so the extras would vanish silently.`);
116
+ }
117
+ const seenZones = new Set();
118
+ for (const c of cfg.cards) {
119
+ if (seenZones.has(c.zone)) {
120
+ throw new Error(`Event ${event.id}: two carousel cards both claim zone ${c.zone}. ` +
121
+ `They would render stacked on the same grid cell.`);
122
+ }
123
+ seenZones.add(c.zone);
124
+ }
125
+ return {
126
+ ...base,
127
+ // 🛑 Empty overlay, not omitted. The two are sibling POSITION_FILL
128
+ // elements; a merge-write that leaves a previous overlay in place would
129
+ // paint it over the cards.
130
+ overlay: undefined,
131
+ cards: cfg.cards.map((c) => toCard(c, opts.eventHref, dim)),
132
+ };
133
+ }
134
+ return {
135
+ ...base,
136
+ cards: [],
137
+ overlay: {
138
+ enabled: true,
139
+ subtitle: cfg.eyebrow,
140
+ title: cfg.title,
141
+ description: cfg.description,
142
+ button: cfg.buttonText
143
+ ? {
144
+ id: `slide-${event.id}-cta`,
145
+ text: cfg.buttonText,
146
+ link: opts.eventHref,
147
+ variant: "primary",
148
+ openInNewTab: false,
149
+ }
150
+ : undefined,
151
+ },
152
+ };
153
+ }
154
+ /**
155
+ * Build every event-owned slide, newest-ordered, and refuse an over-full rail.
156
+ *
157
+ * 🛑 `MAX_ACTIVE_SLIDES` is a real ceiling, not advice — `HeroCarousel` renders
158
+ * the first five and a sixth active slide is authored, stored, and never seen
159
+ * by anyone. Failing here is what makes that visible at seed time rather than
160
+ * as a silently missing promotion.
161
+ */
162
+ export function buildSlidesFromEvents(events, resolveHref, createdBy) {
163
+ const slides = events
164
+ .map((e) => buildSlideFromEvent(e, { eventHref: resolveHref(e), createdBy }))
165
+ .filter((s) => s !== null)
166
+ .sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
167
+ const activeCount = slides.filter((s) => s.active).length;
168
+ if (activeCount > MAX_ACTIVE_SLIDES) {
169
+ throw new Error(`${activeCount} active event slides but MAX_ACTIVE_SLIDES is ${MAX_ACTIVE_SLIDES}. ` +
170
+ `The extras would never render. Deactivate one, or fold several deals into ` +
171
+ `a single cards slide — that is what the 6 zones are for.`);
172
+ }
173
+ return slides;
174
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Chart specs for event pages — pure builders, no React, no recharts.
3
+ *
4
+ * Kept free of any import that reaches the DOM so a Server Component can call
5
+ * these and hand the result to `<Chart>`, whose own `"use client"` boundary is
6
+ * internal (`chart-spec.ts` is deliberately the same shape of module).
7
+ *
8
+ * ## 🛑 EVERY NUMBER HERE IS MEASURED, NOT ILLUSTRATIVE
9
+ *
10
+ * A chart asserts "we measured this". So each builder returns `null` rather
11
+ * than a plausible-looking shape when it has nothing real to plot — an absent
12
+ * chart says "not enough data yet", a flat or invented one says "we measured,
13
+ * and this is the result", which is a different and false claim.
14
+ *
15
+ * @tag domain:events
16
+ * @tag layer:utils
17
+ */
18
+ import type { ChartSpec } from "../../../ui/components/chart-spec";
19
+ import type { CouponDocument } from "../../promotions/schemas/firestore";
20
+ import type { AttachedCoupon } from "../../promotions/constants/attached-coupons";
21
+ /**
22
+ * Poll results as bars.
23
+ *
24
+ * The data already existed and was rendered as the plain string
25
+ * "12 votes (34%)" — `getEventPollResults` computes both server-side and
26
+ * `EventLeaderboard` printed them in a flex row. This is the same numbers,
27
+ * shaped so the differences between options are visible at a glance.
28
+ */
29
+ export declare function buildPollChart(pollConfig: {
30
+ options?: {
31
+ id: string;
32
+ label: string;
33
+ }[];
34
+ } | undefined, results: {
35
+ optionId: string;
36
+ count: number;
37
+ }[] | undefined): ChartSpec | null;
38
+ export declare function buildCouponSavingsChart(coupons: AttachedCoupon[]): ChartSpec | null;
39
+ /**
40
+ * Likes over time. Data comes from `getLikeTrend`, which returns `null` below
41
+ * `LIKE_TREND_MIN_POINTS` so a two-like event plots nothing rather than a flat
42
+ * line with axes.
43
+ */
44
+ export declare function buildLikeTrendChart(trend: {
45
+ categories: string[];
46
+ values: number[];
47
+ } | null): ChartSpec | null;
48
+ export interface SaleProgress {
49
+ /** 0–100, how far through the sale window we are. */
50
+ elapsedPercent: number;
51
+ /** Human remaining time, or null once it has ended. */
52
+ endsAt: Date | null;
53
+ /** 0–100 of a coupon's total redemption allowance, when one is capped. */
54
+ claimedPercent: number | null;
55
+ claimedLabel: string | null;
56
+ }
57
+ /**
58
+ * Time elapsed in the sale window, and redemptions against a real cap.
59
+ *
60
+ * 🛑 `claimedPercent` IS null UNLESS A REAL CAP EXISTS. `usage.totalLimit` is
61
+ * optional, and an uncapped coupon has no denominator — rendering a progress
62
+ * bar against an invented 100 would show a bar filling toward a limit nobody
63
+ * set. Absent is the honest state and the caller renders only the countdown.
64
+ */
65
+ export declare function buildSaleProgress(startsAt: Date | undefined, endsAt: Date | undefined, coupon?: Pick<CouponDocument, "usage" | "code">, now?: Date): SaleProgress | null;