@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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.63.5",
4
- "head": "6eff0d768f87f31fafd2ca5a5af8868622f94228",
3
+ "version": "4.64.0",
4
+ "head": "51ae7f86674aba82540dc018d8baa92064719f20",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-23T20:06:38.671Z"
7
+ "builtAt": "2026-10-24T02:32:37.960Z"
8
8
  }
@@ -36,7 +36,7 @@ export declare const PUBLIC_CATEGORY_FIELDS: readonly ["id", "name", "slug", "de
36
36
  * Fields withheld, each with the reason. Never remove one without replacing
37
37
  * the reason — "it seemed unused" is how a field ends up public by accident.
38
38
  */
39
- export declare const PRIVATE_CATEGORY_FIELDS: readonly ["position", "subtreeSize", "createdBy", "createdByType", "createdByStoreId", "createdByStoreName", "viewCount", "isBrand", "showOnHomepage", "isSearchable", "searchTxt", "aliases", "brandSlug", "bundleKind", "bundlePrice", "bundleStockStatus", "bundleProductIds", "bundleCategorySlugs", "bundleOriginalTotal", "bundleItemDetails", "contentBody", "descriptionTemplates", "productDefaults", "isTestData", "testDataExpiresAt", "bundleQueryRule", "bundleQueryResolvedAt"];
39
+ export declare const PRIVATE_CATEGORY_FIELDS: readonly ["seoCanonicalPath", "position", "subtreeSize", "createdBy", "createdByType", "createdByStoreId", "createdByStoreName", "viewCount", "isBrand", "showOnHomepage", "isSearchable", "searchTxt", "aliases", "brandSlug", "bundleKind", "bundlePrice", "bundleStockStatus", "bundleProductIds", "bundleCategorySlugs", "bundleOriginalTotal", "bundleItemDetails", "contentBody", "descriptionTemplates", "productDefaults", "isTestData", "testDataExpiresAt", "bundleQueryRule", "bundleQueryResolvedAt"];
40
40
  /**
41
41
  * Project a stored category into the public shape.
42
42
  *
@@ -55,6 +55,19 @@ export const PUBLIC_CATEGORY_FIELDS = [
55
55
  * the reason — "it seemed unused" is how a field ends up public by accident.
56
56
  */
57
57
  export const PRIVATE_CATEGORY_FIELDS = [
58
+ /*
59
+ * The canonical path this category's page declares to crawlers.
60
+ *
61
+ * 🛑 Triaged PRIVATE as the default, because nothing emits it: it is
62
+ * consumed SERVER-side by the metadata builder, which reads the document
63
+ * rather than this projection, and `toCategoryListItem` does not carry it.
64
+ * A client reader would be a second place the canonical is decided, which
65
+ * is the drift `audit-seo-canonical-host` exists to prevent (Root Cause
66
+ * #81 — two owners of the canonical host cost the site its indexing).
67
+ *
68
+ * Move it to PUBLIC only alongside a named client reader.
69
+ */
70
+ "seoCanonicalPath",
58
71
  // Internal DFS coordinates. Useful only to `onCategoryWrite` and
59
72
  // `positionsReconcile`; publishing them invites a client to depend on an
60
73
  // ordering we renumber on every reseed.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Event like actions — the write path, and the paged read for the modal.
3
+ *
4
+ * @tag domain:events
5
+ * @tag layer:server
6
+ * @tag access:server-only
7
+ */
8
+ import { type ActionResult } from "../../../shared/types/action-result";
9
+ import { type EventLikersPage } from "./likes";
10
+ export declare function toggleEventLikeAction(idOrSlug: string, userId: string): Promise<ActionResult<{
11
+ liked: boolean;
12
+ likeCount: number;
13
+ }>>;
14
+ /**
15
+ * One page of likers, plus whether THIS viewer has liked it.
16
+ *
17
+ * 🛑 `viewerLiked` rides along here rather than having its own endpoint
18
+ * because the event page cannot resolve it server-side: that page is
19
+ * publicly CDN-cached, so a session read there would let one visitor's
20
+ * state be served to the next (audit-cdn-cache-headers R3). The client
21
+ * asks once after hydration, and this saves it a second round trip.
22
+ */
23
+ export declare function getEventLikersAction(idOrSlug: string, offset?: number, limit?: number, viewerId?: string): Promise<ActionResult<EventLikersPage & {
24
+ viewerLiked: boolean;
25
+ }>>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Event like actions — the write path, and the paged read for the modal.
3
+ *
4
+ * @tag domain:events
5
+ * @tag layer:server
6
+ * @tag access:server-only
7
+ */
8
+ import { wrapAction } from "../../../shared/types/action-result.js";
9
+ import { eventLikesRepository } from "../../../../features/events/repository/event-likes.repository.js";
10
+ import { resolveEventIdAction } from "../raffle/actions.js";
11
+ import { getEventLikers, viewerHasLiked } from "./likes.js";
12
+ import { NotFoundError } from "../../../../errors/index.js";
13
+ import { LIKERS_PAGE_SIZE } from "../../../../features/events/schemas/likes.js";
14
+ /**
15
+ * Resolve whatever the URL carried into the canonical document id.
16
+ *
17
+ * 🛑 THIS IS LOAD-BEARING, NOT DEFENSIVE. Public event URLs link by SLUG
18
+ * (`/events/festive-cashback-10`), so the route param is usually a slug while
19
+ * the like row's id is `{eventId}__{userId}` and the counter lives at
20
+ * `events/{docId}.stats.likeCount`.
21
+ *
22
+ * Without this, the same person liking from the slug URL and from an id URL
23
+ * would write TWO like rows and increment a counter on a document that may
24
+ * not exist — the same two-identities-for-one-event defect the spin route
25
+ * records as a silent `spinMaxPerUser` bypass. There it was a free reset;
26
+ * here it would be a double-counted like and an orphaned row the tester
27
+ * cascade cannot match.
28
+ */
29
+ async function canonicalEventId(idOrSlug) {
30
+ const resolved = await resolveEventIdAction(idOrSlug);
31
+ const id = resolved.ok ? resolved.data : null;
32
+ if (!id)
33
+ throw new NotFoundError(`Event not found: ${idOrSlug}`);
34
+ return id;
35
+ }
36
+ export async function toggleEventLikeAction(idOrSlug, userId) {
37
+ return wrapAction(async () => {
38
+ const eventId = await canonicalEventId(idOrSlug);
39
+ return eventLikesRepository.toggle(eventId, userId);
40
+ });
41
+ }
42
+ /**
43
+ * One page of likers, plus whether THIS viewer has liked it.
44
+ *
45
+ * 🛑 `viewerLiked` rides along here rather than having its own endpoint
46
+ * because the event page cannot resolve it server-side: that page is
47
+ * publicly CDN-cached, so a session read there would let one visitor's
48
+ * state be served to the next (audit-cdn-cache-headers R3). The client
49
+ * asks once after hydration, and this saves it a second round trip.
50
+ */
51
+ export async function getEventLikersAction(idOrSlug, offset = 0, limit = LIKERS_PAGE_SIZE, viewerId) {
52
+ return wrapAction(async () => {
53
+ const eventId = await canonicalEventId(idOrSlug);
54
+ /*
55
+ * Clamped, because `limit` arrives from a query string. An unclamped
56
+ * caller-supplied page size is an unbounded read against Rule #6's
57
+ * budget — `?limit=100000` would be a full-collection scan plus one user
58
+ * lookup per row.
59
+ */
60
+ const safeLimit = Math.min(Math.max(1, limit), LIKERS_PAGE_SIZE);
61
+ const [page, viewerLiked] = await Promise.all([
62
+ getEventLikers(eventId, safeLimit, Math.max(0, offset)),
63
+ viewerHasLiked(eventId, viewerId),
64
+ ]);
65
+ return { ...page, viewerLiked };
66
+ });
67
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Event likes — the server read path, and the only place a liker's identity
3
+ * is assembled.
4
+ *
5
+ * @tag domain:events
6
+ * @tag layer:server
7
+ * @tag access:server-only
8
+ */
9
+ import { type PublicLiker } from "../../../../features/events/schemas/likes";
10
+ export interface EventLikersPage {
11
+ likers: PublicLiker[];
12
+ /** True when more rows exist past this page. */
13
+ hasMore: boolean;
14
+ }
15
+ /**
16
+ * The most recent likers, resolved to names and faces.
17
+ *
18
+ * Two hops, both bounded: one indexed query for the ids, then one batched
19
+ * lookup for the profiles. The join is why the like row stores no name — see
20
+ * `schemas/likes.ts`.
21
+ *
22
+ * A user that cannot be resolved (deleted account, say) is DROPPED rather
23
+ * than rendered as a placeholder: a face with no person behind it is worse
24
+ * than a shorter rail.
25
+ */
26
+ export declare function getEventLikers(eventId: string, limit?: number, offset?: number): Promise<EventLikersPage>;
27
+ /**
28
+ * Cached for the page+metadata pair. `React.cache` memoises per argument
29
+ * list, so the default-arguments call site here and any explicit-limit call
30
+ * site would be two entries — which is why every caller on a page should use
31
+ * the same shape.
32
+ */
33
+ export declare const getEventLikersCached: typeof getEventLikers;
34
+ /** Has the viewer liked it? `false` for a signed-out visitor, with no read. */
35
+ export declare function viewerHasLiked(eventId: string, userId?: string): Promise<boolean>;
36
+ export declare const viewerHasLikedCached: typeof viewerHasLiked;
37
+ /** Days plotted by the engagement chart. */
38
+ export declare const LIKE_TREND_DAYS = 14;
39
+ /**
40
+ * Below this many likes the chart renders NOTHING.
41
+ *
42
+ * 🛑 This threshold is the honest half of the feature. A brand-new event has
43
+ * two likes, and plotting them produces a flat line with a y-axis and a date
44
+ * range — which looks like a measurement and carries no information. An
45
+ * absent chart says "not enough data yet"; a flat one says "we measured, and
46
+ * this is the result", which is a different and false claim.
47
+ */
48
+ export declare const LIKE_TREND_MIN_POINTS = 5;
49
+ export interface LikeTrend {
50
+ /** Day labels, oldest first, e.g. "6 Nov". */
51
+ categories: string[];
52
+ /** Likes received on each of those days. Same length as `categories`. */
53
+ values: number[];
54
+ }
55
+ /**
56
+ * Likes per day over the last `LIKE_TREND_DAYS`, oldest first.
57
+ *
58
+ * Returns `null` — not an empty chart — when there is too little to plot.
59
+ *
60
+ * Bounded by `recentLikeTimes`' own 200-row cap: that is far more than 14
61
+ * days of a realistic event, and it means this cannot become an unbounded
62
+ * collection scan on a popular one (Rule #6).
63
+ */
64
+ export declare function getLikeTrend(eventId: string): Promise<LikeTrend | null>;
65
+ export declare const getLikeTrendCached: typeof getLikeTrend;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Event likes — the server read path, and the only place a liker's identity
3
+ * is assembled.
4
+ *
5
+ * @tag domain:events
6
+ * @tag layer:server
7
+ * @tag access:server-only
8
+ */
9
+ import { cache } from "react";
10
+ import { eventLikesRepository } from "../../../../features/events/repository/event-likes.repository.js";
11
+ import { userRepository } from "../../../../features/auth/repository/user.repository.js";
12
+ import { LIKERS_PREVIEW_LIMIT, } from "../../../../features/events/schemas/likes.js";
13
+ import { normalizeError } from "../../../../errors/normalize.js";
14
+ import { serverLogger } from "../../../../monitoring/index.js";
15
+ /**
16
+ * Project a user document down to the three fields a face needs.
17
+ *
18
+ * 🛑 AN ALLOW-LIST, NEVER A SPREAD. The user document carries email, phone,
19
+ * role, store ownership and encrypted PII; a spread-and-delete publishes
20
+ * every field nobody thought to remove, including fields the interface does
21
+ * not declare — which is precisely how `adSettings.providerCredentials` ended
22
+ * up on a public endpoint (Root Cause #70).
23
+ *
24
+ * 🛑 AND NOT `maskPii`. The obvious-looking alternative is to read the name
25
+ * off the event ENTRY and mask it, which is what the leaderboard and the spin
26
+ * results do. That renders "A*** K***" — correct for a leaderboard, useless
27
+ * as a face. `displayName` and `photoURL` are in `USER_PUBLIC_FIELDS`, are
28
+ * absent from `USER_PII_FIELDS`, and already render on public profile pages,
29
+ * so taking them from the user document publishes nothing new.
30
+ */
31
+ function toPublicLiker(doc) {
32
+ const uid = doc?.uid ?? doc?.id;
33
+ if (!uid)
34
+ return null;
35
+ return {
36
+ uid: String(uid),
37
+ // A real account with no display name renders as a person, not a blank.
38
+ displayName: doc?.displayName?.trim() || "A collector",
39
+ photoURL: doc?.photoURL || undefined,
40
+ };
41
+ }
42
+ /**
43
+ * The most recent likers, resolved to names and faces.
44
+ *
45
+ * Two hops, both bounded: one indexed query for the ids, then one batched
46
+ * lookup for the profiles. The join is why the like row stores no name — see
47
+ * `schemas/likes.ts`.
48
+ *
49
+ * A user that cannot be resolved (deleted account, say) is DROPPED rather
50
+ * than rendered as a placeholder: a face with no person behind it is worse
51
+ * than a shorter rail.
52
+ */
53
+ export async function getEventLikers(eventId, limit = LIKERS_PREVIEW_LIMIT, offset = 0) {
54
+ try {
55
+ // One extra row is the cheapest possible "is there a next page" — it
56
+ // avoids a count query, which Firestore bills per matched document.
57
+ const ids = await eventLikesRepository.recentLikerIds(eventId, limit + 1, offset);
58
+ const hasMore = ids.length > limit;
59
+ const pageIds = hasMore ? ids.slice(0, limit) : ids;
60
+ const docs = await Promise.all(pageIds.map((uid) => userRepository
61
+ .findByUid(uid)
62
+ .catch(() => null)));
63
+ return {
64
+ likers: docs
65
+ .map((d) => toPublicLiker(d))
66
+ .filter((l) => l !== null),
67
+ hasMore,
68
+ };
69
+ }
70
+ catch (err) {
71
+ const e = normalizeError(err);
72
+ serverLogger.warn("getEventLikers failed", { eventId, message: e.message });
73
+ // An empty rail is a degraded page; a thrown error is a dead one. The
74
+ // count beside it comes from the event document and still renders.
75
+ return { likers: [], hasMore: false };
76
+ }
77
+ }
78
+ /**
79
+ * Cached for the page+metadata pair. `React.cache` memoises per argument
80
+ * list, so the default-arguments call site here and any explicit-limit call
81
+ * site would be two entries — which is why every caller on a page should use
82
+ * the same shape.
83
+ */
84
+ export const getEventLikersCached = cache(getEventLikers);
85
+ /** Has the viewer liked it? `false` for a signed-out visitor, with no read. */
86
+ export async function viewerHasLiked(eventId, userId) {
87
+ if (!userId)
88
+ return false;
89
+ return eventLikesRepository.hasLiked(eventId, userId);
90
+ }
91
+ export const viewerHasLikedCached = cache(viewerHasLiked);
92
+ /** Days plotted by the engagement chart. */
93
+ export const LIKE_TREND_DAYS = 14;
94
+ /**
95
+ * Below this many likes the chart renders NOTHING.
96
+ *
97
+ * 🛑 This threshold is the honest half of the feature. A brand-new event has
98
+ * two likes, and plotting them produces a flat line with a y-axis and a date
99
+ * range — which looks like a measurement and carries no information. An
100
+ * absent chart says "not enough data yet"; a flat one says "we measured, and
101
+ * this is the result", which is a different and false claim.
102
+ */
103
+ export const LIKE_TREND_MIN_POINTS = 5;
104
+ /**
105
+ * Likes per day over the last `LIKE_TREND_DAYS`, oldest first.
106
+ *
107
+ * Returns `null` — not an empty chart — when there is too little to plot.
108
+ *
109
+ * Bounded by `recentLikeTimes`' own 200-row cap: that is far more than 14
110
+ * days of a realistic event, and it means this cannot become an unbounded
111
+ * collection scan on a popular one (Rule #6).
112
+ */
113
+ export async function getLikeTrend(eventId) {
114
+ const times = await eventLikesRepository.recentLikeTimes(eventId);
115
+ if (times.length < LIKE_TREND_MIN_POINTS)
116
+ return null;
117
+ const now = new Date();
118
+ const buckets = [];
119
+ const index = new Map();
120
+ for (let i = LIKE_TREND_DAYS - 1; i >= 0; i -= 1) {
121
+ const d = new Date(now);
122
+ d.setDate(d.getDate() - i);
123
+ const key = d.toISOString().slice(0, 10);
124
+ index.set(key, buckets.length);
125
+ buckets.push({
126
+ label: d.toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
127
+ count: 0,
128
+ });
129
+ }
130
+ for (const t of times) {
131
+ const slot = index.get(t.toISOString().slice(0, 10));
132
+ // Older than the window — `recentLikeTimes` returns newest-first over a
133
+ // 200-row cap, so this is the normal case for a long-running event.
134
+ if (slot !== undefined)
135
+ buckets[slot].count += 1;
136
+ }
137
+ // Every like could be older than the window even though the count cleared
138
+ // the threshold, which would plot fourteen zeroes.
139
+ if (buckets.every((b) => b.count === 0))
140
+ return null;
141
+ return {
142
+ categories: buckets.map((b) => b.label),
143
+ values: buckets.map((b) => b.count),
144
+ };
145
+ }
146
+ export const getLikeTrendCached = cache(getLikeTrend);
@@ -6,3 +6,6 @@ export { buildSitemap } from "./sitemap";
6
6
  export type { SitemapOptions } from "./sitemap";
7
7
  export { buildDefaultOgImage, DEFAULT_OG_SIZE, resolveOgImageUrl } from "./og";
8
8
  export type { DefaultOgOptions } from "./og";
9
+ export { productDetailSlugs, brandDetailSlugs, franchiseDetailSlugs, blogPostSlugs, beyDetailSlugs, partDetailParams, } from "./static-params";
10
+ export { brandLinks, franchiseLinks, categoryRootLinks } from "./taxonomy-links";
11
+ export type { TaxonomyLink } from "./taxonomy-links";
@@ -2,3 +2,5 @@ export { buildRobots } from "./robots.js";
2
2
  export { buildManifest } from "./manifest.js";
3
3
  export { buildSitemap } from "./sitemap.js";
4
4
  export { buildDefaultOgImage, DEFAULT_OG_SIZE, resolveOgImageUrl } from "./og.js";
5
+ export { productDetailSlugs, brandDetailSlugs, franchiseDetailSlugs, blogPostSlugs, beyDetailSlugs, partDetailParams, } from "./static-params.js";
6
+ export { brandLinks, franchiseLinks, categoryRootLinks } from "./taxonomy-links.js";
@@ -0,0 +1 @@
1
+ export declare const PRODUCT_DETAIL_LISTING_TYPES: readonly string[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which listing types actually RENDER at `/products/{slug}`.
3
+ *
4
+ * DERIVED from each plugin's own `detailRoute`, never hand-written. Two SEO
5
+ * surfaces need this set — the sitemap (what to submit) and
6
+ * `generateStaticParams` (what to prerender) — and a third, the page itself,
7
+ * enforces it by `permanentRedirect`ing a non-standard type away.
8
+ *
9
+ * 🛑 It used to be a literal `["standard", "art", "stickers"]` in `sitemap.ts`.
10
+ * When `spares` was added as a tenth listing type pointing at the same route,
11
+ * that literal was not updated, so the product became renderable at
12
+ * `/products/{slug}` and was never submitted — measured 2026-10-24, 14 product
13
+ * URLs in the sitemap against 15 that resolve. That is Root Cause #61's shape
14
+ * (N hand-written enumerations of one union, only the compile-checked ones
15
+ * staying correct), and deriving is the documented fix.
16
+ *
17
+ * Comparing rendered routes rather than naming types keeps it honest: a
18
+ * plugin that is repointed at its own page leaves this set automatically.
19
+ */
20
+ import { ALL_LISTING_TYPES } from "../../../shared/listing-types/feature-flags.js";
21
+ import { pluginFor } from "../../../shared/listing-types/_registry.js";
22
+ import { ROUTES } from "../../../../next/routing/route-map.js";
23
+ /** An arbitrary probe slug — only the SHAPE of the resolved route matters. */
24
+ const PROBE = "__route_probe__";
25
+ const PRODUCT_DETAIL_ROUTE = String(ROUTES.PUBLIC.PRODUCT_DETAIL(PROBE));
26
+ export const PRODUCT_DETAIL_LISTING_TYPES = ALL_LISTING_TYPES.filter((type) => pluginFor(type).detailRoute(PROBE) === PRODUCT_DETAIL_ROUTE);
@@ -36,7 +36,26 @@ export function buildRobots({ siteUrl }) {
36
36
  "/preview/",
37
37
  "/track/",
38
38
  "/unauthorized/",
39
- "/_next/",
39
+ /*
40
+ * 🛑 `/_next/` IS DELIBERATELY NOT DISALLOWED — do not re-add it.
41
+ *
42
+ * It was here until 2026-10-24 and it was blocking Googlebot from
43
+ * fetching the app's own JS and CSS chunks, i.e. from RENDERING the
44
+ * page at all. Google's guidance is explicit that blocking resources
45
+ * needed for rendering harms how a page is understood.
46
+ *
47
+ * It was actively load-bearing here rather than merely untidy: every
48
+ * dynamic route streams its metadata late, so the `<title>` lands
49
+ * inside `<body>` (measured at byte 563,190 of a 747 KB document,
50
+ * with the `<head>` ending at 8,180 carrying no title at all). The
51
+ * one mechanism that recovers it is React hoisting those tags into
52
+ * `<head>` during hydration — which cannot happen if the crawler is
53
+ * forbidden from downloading the script that performs the hoist.
54
+ *
55
+ * Blocking it buys nothing: these are immutable, content-hashed
56
+ * static assets that are not indexable documents, and Google does not
57
+ * list them as search results.
58
+ */
40
59
  "/profile/*/edit",
41
60
  ],
42
61
  },