@mohasinac/appkit 4.63.5 → 4.64.1
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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +87 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/shared/actions/action-registry.js +61 -0
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/constants/api-endpoints.d.ts +24 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +17 -4
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/repository/store.repository.js +23 -0
- package/dist/features/stores/schemas/firestore.d.ts +60 -1
- package/dist/features/stores/schemas/firestore.js +18 -0
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/next/routing/route-map.d.ts +3 -1
- package/dist/next/routing/route-map.js +13 -0
- package/dist/schemas/registry.d.ts +10 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +10 -0
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- 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;
|