@mohasinac/appkit 4.47.0 → 4.48.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.
Files changed (173) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
  38. package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
  103. package/dist/features/seller/components/SellerProductShell.js +2 -2
  104. package/dist/features/seller/repository/offer.repository.js +21 -6
  105. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  106. package/dist/features/seller/schemas/product-form.js +1 -1
  107. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  108. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  109. package/dist/features/server-errors/schemas/firestore.js +2 -0
  110. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  111. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  112. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  113. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  114. package/dist/features/stores/repository/store.repository.js +11 -3
  115. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  116. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  117. package/dist/features/tester/seed-data/authored/index.js +2 -0
  118. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  119. package/dist/index.d.ts +8 -2
  120. package/dist/index.js +1 -1
  121. package/dist/next/routing/route-map.d.ts +14 -0
  122. package/dist/next/routing/route-map.js +26 -0
  123. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  124. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  125. package/dist/repositories/index.d.ts +1 -0
  126. package/dist/repositories/index.js +4 -0
  127. package/dist/schemas/registry.d.ts +3 -3
  128. package/dist/security/index.d.ts +1 -1
  129. package/dist/security/index.js +1 -1
  130. package/dist/security/pii-schemas.d.ts +17 -0
  131. package/dist/security/pii-schemas.js +15 -7
  132. package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
  133. package/dist/seed/_helpers/category-content-generated.js +23 -7
  134. package/dist/seed/_helpers/category-models.d.ts +1 -1
  135. package/dist/seed/_helpers/category-models.js +25 -1
  136. package/dist/seed/_helpers/category-names.d.ts +54 -0
  137. package/dist/seed/_helpers/category-names.js +118 -0
  138. package/dist/seed/_helpers/character-features.d.ts +1 -1
  139. package/dist/seed/_helpers/character-features.js +5 -4
  140. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  141. package/dist/seed/categories-seed-data.js +55 -1
  142. package/dist/seed/grouped-listings-seed-data.js +2 -2
  143. package/dist/seed/products-art-seed-data.js +2 -1
  144. package/dist/seed/products-auctions-seed-data.js +42 -22
  145. package/dist/seed/products-classifieds-seed-data.js +21 -3
  146. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  147. package/dist/seed/products-live-items-seed-data.js +2 -1
  148. package/dist/seed/products-preorders-seed-data.js +26 -11
  149. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  150. package/dist/seed/products-standard-seed-data.js +26 -25
  151. package/dist/seed/products-stickers-seed-data.js +12 -11
  152. package/dist/server.d.ts +8 -0
  153. package/dist/server.js +15 -0
  154. package/dist/styles.css +113 -1
  155. package/dist/tailwind-utilities.css +1 -1
  156. package/dist/tokens/themes/sunset.js +17 -0
  157. package/dist/tokens/tokens.css +5 -0
  158. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  159. package/dist/ui/components/Breadcrumb.js +2 -2
  160. package/dist/ui/components/Breadcrumb.style.css +37 -0
  161. package/dist/ui/components/Details.d.ts +11 -1
  162. package/dist/ui/components/Details.js +2 -2
  163. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  164. package/dist/ui/components/ImageMosaic.js +46 -0
  165. package/dist/ui/components/ImageMosaic.style.css +69 -0
  166. package/dist/ui/components/SplitTile.d.ts +34 -0
  167. package/dist/ui/components/SplitTile.js +28 -0
  168. package/dist/ui/components/index.style.css +1 -0
  169. package/dist/ui/index.d.ts +4 -0
  170. package/dist/ui/index.js +4 -0
  171. package/dist/utils/search-txt-query.d.ts +77 -0
  172. package/dist/utils/search-txt-query.js +50 -28
  173. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { buildOrderSearchTxt } from "../../../utils/search-txt-builders.js";
2
- import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query.js";
2
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
3
3
  import { normalizeError } from "../../../errors/normalize.js";
4
4
  import { DatabaseError, NotFoundError } from "../../../errors/index.js";
5
5
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
@@ -314,12 +314,19 @@ class OrderRepository extends BaseRepository {
314
314
  // `in`/`not-in`/`array-contains-any` family separately.
315
315
  baseQuery = baseQuery.where(ORDER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
316
316
  }
317
- const result = await this.sieveQuery(model, OrderRepository.SELLER_SIEVE_FIELDS, {
317
+ /*
318
+ * One bounded window, one raised `maxPageSize`, one page slice — see
319
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
320
+ * repositories each ended up with two of them, which reads as a working
321
+ * multi-word search and silently cannot reach page 2.
322
+ */
323
+ const win = beginSearchWindow(model, plan);
324
+ const result = await this.sieveQuery(win.model, OrderRepository.SELLER_SIEVE_FIELDS, {
318
325
  baseQuery,
319
326
  defaultPageSize: 20,
320
- maxPageSize: 100,
327
+ maxPageSize: win.maxPageSize(100),
321
328
  });
322
- return refineSearchTxt(result, plan.rest);
329
+ return win.finish(result);
323
330
  }
324
331
  async listForUser(userId, model) {
325
332
  const baseQuery = this.getCollection().where(ORDER_FIELDS.USER_ID, "==", userId);
@@ -347,12 +354,19 @@ class OrderRepository extends BaseRepository {
347
354
  if (plan.head) {
348
355
  baseQuery = baseQuery.where(ORDER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
349
356
  }
350
- const result = await this.sieveQuery(model, OrderRepository.ADMIN_SIEVE_FIELDS, {
357
+ /*
358
+ * One bounded window, one raised `maxPageSize`, one page slice — see
359
+ * `beginSearchWindow`. Hand-writing the three is how the other ten
360
+ * repositories each ended up with two of them, which reads as a working
361
+ * multi-word search and silently cannot reach page 2.
362
+ */
363
+ const win = beginSearchWindow(model, plan);
364
+ const result = await this.sieveQuery(win.model, OrderRepository.ADMIN_SIEVE_FIELDS, {
351
365
  baseQuery,
352
366
  defaultPageSize: 50,
353
- maxPageSize: 200,
367
+ maxPageSize: win.maxPageSize(200),
354
368
  });
355
- return refineSearchTxt(result, plan.rest);
369
+ return win.finish(result);
356
370
  }
357
371
  /**
358
372
  * Append one refund event to the order and mark it non-contestable.
@@ -226,6 +226,17 @@ export interface OrderDocumentItem {
226
226
  /** I8 — the brand display name at order time, for the same reason. */
227
227
  brand?: string;
228
228
  condition?: string;
229
+ /**
230
+ * I9 — which physical variant of a PART this unit was, snapshotted.
231
+ *
232
+ * 🛑 Snapshotted for the same reason `condition` and `gstRate` are: the
233
+ * price index slices by colourway, and reading the product's CURRENT
234
+ * colourway would retroactively re-file an old sale under a value the seller
235
+ * changed later. For a part this is a bigger price variable than condition —
236
+ * the silver Metal Ball and the transparent-black one are the same mould
237
+ * with the same stats, and one is rare and costly.
238
+ */
239
+ colourway?: string;
229
240
  /** SB8-F â€" per-item reveal status; flips through pending â†' open â†' revealed/closed. */
230
241
  prizeRevealStatus?: "pending" | "open" | "closed" | "revealed";
231
242
  /** Set when the buyer cancels this specific line item post-order (partial cancellation). */
@@ -0,0 +1,31 @@
1
+ import { type MeetupCity } from "../constants/meetup-cities";
2
+ export interface CityCollectiblesViewProps {
3
+ city: MeetupCity;
4
+ }
5
+ /**
6
+ * D5 — local SEO: the collectibles people are selling in ONE city.
7
+ *
8
+ * ## 🛑 What makes this a real page rather than a doorway
9
+ *
10
+ * Google's own guidance on local pages is that they must carry content specific
11
+ * to the place; a template with the city name swapped in is the doorway pattern
12
+ * it penalises. So everything on this page is city-SPECIFIC and comes from the
13
+ * city's own data:
14
+ *
15
+ * - the classified listings whose seller named THIS city as the meet-up
16
+ * point, which is a real field (`classified.meetupArea.city`) with its own
17
+ * composite index, not a text match on a description;
18
+ * - the count, stated plainly;
19
+ * - and when the count is zero it SAYS so, with the national browse as the
20
+ * next step. An empty grid under a city headline is the failure mode this
21
+ * page type is famous for.
22
+ *
23
+ * ## 🛑 Classifieds only, deliberately
24
+ *
25
+ * A shipped listing has no city — it has a seller's warehouse, which is not
26
+ * where the buyer is and not a fact about the buyer's town. Only the classified
27
+ * type carries a meet-up place, so only classifieds can honestly appear under a
28
+ * city heading. Padding the page with shippable national inventory is what
29
+ * turns a local page into the same page six times.
30
+ */
31
+ export declare function CityCollectiblesView({ city }: CityCollectiblesViewProps): Promise<import("react").JSX.Element>;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ROUTES } from "../../../next/routing/route-map.js";
3
+ import { PAGE_CONTAINER } from "../../../_internal/shared/styles/page.js";
4
+ import { THEMED_BG_PRIMARY } from "../../../_internal/shared/styles/themed.js";
5
+ import { TABLE_KEYS } from "../../../constants/table-keys.js";
6
+ import { safeRead } from "../../../errors/safe-read.js";
7
+ import { Alert, Div, Grid, Heading, Row, Section, Stack, Text, TextLink, } from "../../../ui/index.js";
8
+ import { listPublicProducts } from "../../../_internal/server/features/products/list-public.js";
9
+ import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
10
+ import { MEETUP_CITIES } from "../constants/meetup-cities.js";
11
+ import { ProductGrid } from "./ProductGrid.js";
12
+ const __P = {
13
+ p5: "p-[var(--appkit-space-5)]",
14
+ };
15
+ /** Listings read per city page. One bounded query, Rule #6. */
16
+ const CITY_LISTING_LIMIT = 24;
17
+ /**
18
+ * D5 — local SEO: the collectibles people are selling in ONE city.
19
+ *
20
+ * ## 🛑 What makes this a real page rather than a doorway
21
+ *
22
+ * Google's own guidance on local pages is that they must carry content specific
23
+ * to the place; a template with the city name swapped in is the doorway pattern
24
+ * it penalises. So everything on this page is city-SPECIFIC and comes from the
25
+ * city's own data:
26
+ *
27
+ * - the classified listings whose seller named THIS city as the meet-up
28
+ * point, which is a real field (`classified.meetupArea.city`) with its own
29
+ * composite index, not a text match on a description;
30
+ * - the count, stated plainly;
31
+ * - and when the count is zero it SAYS so, with the national browse as the
32
+ * next step. An empty grid under a city headline is the failure mode this
33
+ * page type is famous for.
34
+ *
35
+ * ## 🛑 Classifieds only, deliberately
36
+ *
37
+ * A shipped listing has no city — it has a seller's warehouse, which is not
38
+ * where the buyer is and not a fact about the buyer's town. Only the classified
39
+ * type carries a meet-up place, so only classifieds can honestly appear under a
40
+ * city heading. Padding the page with shippable national inventory is what
41
+ * turns a local page into the same page six times.
42
+ */
43
+ export async function CityCollectiblesView({ city }) {
44
+ const page = { container: PAGE_CONTAINER };
45
+ const result = await safeRead(() => listPublicProducts({
46
+ listingTypes: ["classified"],
47
+ // The city is a TYPE FACET, resolved to `classified.meetupArea.city` by
48
+ // the shared query — never a hand-written Sieve clause here.
49
+ typeFacets: { [TABLE_KEYS.CITY]: city.city },
50
+ availability: "available",
51
+ page: 1,
52
+ pageSize: CITY_LISTING_LIMIT,
53
+ }), {
54
+ route: "/collectibles/[city]",
55
+ key: "city.classifieds",
56
+ fallback: null,
57
+ });
58
+ /*
59
+ * The metadata comes off the result FIRST, then the rows are narrowed — so
60
+ * nothing reads the raw result after the projection exists. That ordering is
61
+ * what `audit-projection-shadow` is for: a narrowed copy beside a live
62
+ * source is how a raw document reaches a client prop (Root Cause #70), and
63
+ * the rule holds even when the later read is innocent.
64
+ *
65
+ * 🛑 `truncated` means `total` is a FLOOR, so it renders as "24+" and never
66
+ * as an exact figure — the same rule every other bounded listing follows. A
67
+ * floor presented as a total compounds into a wrong "page 2 of 1".
68
+ */
69
+ const total = result?.total ?? 0;
70
+ const truncated = result?.truncated === true;
71
+ const items = hidePublicTestData(result?.items ?? []);
72
+ const countLabel = truncated ? `${items.length}+` : String(total);
73
+ const others = MEETUP_CITIES.filter((c) => c.slug !== city.slug);
74
+ return (_jsxs(Div, { children: [_jsx(Section, { className: THEMED_BG_PRIMARY, padding: "y-2xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Heading, { level: 1, size: "3xl", mdSize: "4xl", weight: "bold", className: "mb-3", children: `Collectibles for sale in ${city.city}` }), _jsx(Text, { size: "lg", color: "muted", className: "max-w-3xl", children: `Classified listings where the seller meets buyers in ${city.city}, ${city.state} — pay in person, inspect before you buy, no shipping involved.` })] }) }), _jsx(Section, { padding: "y-xl", children: _jsx(Div, { className: page.container.lg, children: items.length > 0 ? (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "center", justify: "between", wrap: true, className: "mb-4", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", children: `${countLabel} listing${items.length === 1 ? "" : "s"} in ${city.city}` }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.CLASSIFIED), children: "All classifieds" })] }), _jsx(ProductGrid, { products: items })] })) : (
75
+ /*
76
+ * 🛑 Says what is true. A city page with no listings is a normal
77
+ * state for a local marketplace — the dishonest version is an empty
78
+ * grid under a confident headline, which reads as a broken page and
79
+ * is also the doorway pattern this page type is penalised for.
80
+ */
81
+ _jsxs(Alert, { variant: "info", children: [_jsx(Text, { weight: "semibold", children: `Nobody is selling in ${city.city} right now` }), _jsx(Text, { size: "sm", children: `Classified listings come and go quickly — this page lists them whenever a seller names ${city.city} as their meet-up point. In the meantime, everything on LetItRip ships nationwide.` }), _jsxs(Row, { gap: "md", wrap: true, className: "mt-3", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.PRODUCTS), children: "Browse everything" }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.CLASSIFIED), children: "All classifieds" })] })] })) }) }), _jsx(Section, { padding: "y-xl", children: _jsxs(Div, { className: page.container.lg, children: [_jsx(Stack, { gap: "md", children: _jsxs(Div, { className: __P.p5, surface: "card", rounded: "xl", border: "subtle", children: [_jsx(Heading, { level: 2, size: "lg", weight: "semibold", className: "mb-2", children: `Meeting a seller in ${city.city}` }), _jsx(Text, { color: "muted", children: "Meet somewhere public and busy, in daylight. Bring the exact amount if you are paying cash, and check the item against its listing photos before you hand anything over \u2014 a classified sale is between you and the seller, so LetItRip's buyer protection does not cover a cash handover." }), _jsxs(Row, { gap: "md", wrap: true, className: "mt-3", children: [_jsx(TextLink, { href: String(ROUTES.PUBLIC.SPOT_A_FAKE), children: "How to spot a fake" }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.SCAMS), children: "Scam registry" })] })] }) }), _jsx(Heading, { level: 2, size: "lg", weight: "semibold", className: "mt-8 mb-3", children: "Other cities" }), _jsx(Grid, { cols: "navTiles", gap: "sm", children: others.map((c) => (_jsx(TextLink, { href: String(ROUTES.PUBLIC.CITY_COLLECTIBLES(c.slug)), children: c.city }, c.slug))) })] }) })] }));
82
+ }
@@ -0,0 +1,45 @@
1
+ import { type PriceIndexBand } from "../../categories/repository/price-index.repository";
2
+ export interface MarketPriceAnchorProps {
3
+ /** The band for this leaf AND this listing type, or null when there is none. */
4
+ band: PriceIndexBand | null | undefined;
5
+ /** Days of completed sales the band covers — stated, never implied. */
6
+ windowDays: number | null | undefined;
7
+ currency?: string;
8
+ /** This listing's own price, so the comparison can be stated as a direction. */
9
+ price?: number | null;
10
+ }
11
+ /**
12
+ * H2 — the one persuasion pattern on this site that is made of measurement.
13
+ *
14
+ * ## 🛑 Why an anchor at all, and why THIS anchor
15
+ *
16
+ * Price anchoring works; the usual implementation is a fabricated "RRP ₹2,499"
17
+ * beside a ₹1,299 price, and in this market it is close to universal. It is
18
+ * also a lie, and the plan's H2 boundary is explicit that a persuasion pattern
19
+ * here has to be a true statement a visitor could verify.
20
+ *
21
+ * This one is: **what this model has actually sold for on this site**, from
22
+ * `priceIndexRollup`'s realised-sale bands. A seller can check it against their
23
+ * own completed orders and a buyer can click through to the tool that shows the
24
+ * distribution. Nothing is invented and nothing is rounded in our favour.
25
+ *
26
+ * ## 🛑 Three rules that keep it honest
27
+ *
28
+ * 1. **Silent below `PRICE_INDEX_MIN_CONFIDENT_SAMPLES`.** A median of two
29
+ * sales is arithmetic, not a market. The tool page says "not enough data
30
+ * yet" because that is a legitimate answer to a direct question; a product
31
+ * page says NOTHING, because an unasked-for caveat beside a buy button is
32
+ * noise rather than information.
33
+ * 2. **The window is stated.** "over 90 days" is part of the claim — a median
34
+ * with no period attached is unfalsifiable.
35
+ * 3. **The direction is stated plainly, including when it is unflattering.**
36
+ * If this listing is ABOVE what the model usually sells for, it says so. An
37
+ * anchor that only ever renders when it flatters the price is a filter, not
38
+ * a measurement, and a buyer who notices that once never believes the
39
+ * number again.
40
+ *
41
+ * 🛑 Wrapped in `<PricesOnly>`: it is a SECONDARY money figure beside an
42
+ * already-gated price, so a signed-out visitor sees nothing rather than a
43
+ * second "sign in to see price" (CLAUDE.md § Guest price gating).
44
+ */
45
+ export declare function MarketPriceAnchor({ band, windowDays, currency, price, }: MarketPriceAnchorProps): import("react").JSX.Element | null;
@@ -0,0 +1,62 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { PRICE_INDEX_MIN_CONFIDENT_SAMPLES } from "../../categories/repository/price-index.repository.js";
3
+ import { formatCurrency } from "../../../utils/number.formatter.js";
4
+ import { PricesOnly } from "../../../ui/components/GatedPrice.js";
5
+ import { Text } from "../../../ui/components/Typography.js";
6
+ /**
7
+ * H2 — the one persuasion pattern on this site that is made of measurement.
8
+ *
9
+ * ## 🛑 Why an anchor at all, and why THIS anchor
10
+ *
11
+ * Price anchoring works; the usual implementation is a fabricated "RRP ₹2,499"
12
+ * beside a ₹1,299 price, and in this market it is close to universal. It is
13
+ * also a lie, and the plan's H2 boundary is explicit that a persuasion pattern
14
+ * here has to be a true statement a visitor could verify.
15
+ *
16
+ * This one is: **what this model has actually sold for on this site**, from
17
+ * `priceIndexRollup`'s realised-sale bands. A seller can check it against their
18
+ * own completed orders and a buyer can click through to the tool that shows the
19
+ * distribution. Nothing is invented and nothing is rounded in our favour.
20
+ *
21
+ * ## 🛑 Three rules that keep it honest
22
+ *
23
+ * 1. **Silent below `PRICE_INDEX_MIN_CONFIDENT_SAMPLES`.** A median of two
24
+ * sales is arithmetic, not a market. The tool page says "not enough data
25
+ * yet" because that is a legitimate answer to a direct question; a product
26
+ * page says NOTHING, because an unasked-for caveat beside a buy button is
27
+ * noise rather than information.
28
+ * 2. **The window is stated.** "over 90 days" is part of the claim — a median
29
+ * with no period attached is unfalsifiable.
30
+ * 3. **The direction is stated plainly, including when it is unflattering.**
31
+ * If this listing is ABOVE what the model usually sells for, it says so. An
32
+ * anchor that only ever renders when it flatters the price is a filter, not
33
+ * a measurement, and a buyer who notices that once never believes the
34
+ * number again.
35
+ *
36
+ * 🛑 Wrapped in `<PricesOnly>`: it is a SECONDARY money figure beside an
37
+ * already-gated price, so a signed-out visitor sees nothing rather than a
38
+ * second "sign in to see price" (CLAUDE.md § Guest price gating).
39
+ */
40
+ export function MarketPriceAnchor({ band, windowDays, currency = "INR", price, }) {
41
+ if (!band)
42
+ return null;
43
+ if (!band.sampleSize || band.sampleSize < PRICE_INDEX_MIN_CONFIDENT_SAMPLES)
44
+ return null;
45
+ if (!Number.isFinite(band.median) || band.median <= 0)
46
+ return null;
47
+ const window = typeof windowDays === "number" && windowDays > 0 ? windowDays : null;
48
+ const sales = `${band.sampleSize} sale${band.sampleSize === 1 ? "" : "s"}`;
49
+ const period = window ? ` in the last ${window} days` : "";
50
+ /*
51
+ * The direction, computed against a 5% band so a rounding difference is not
52
+ * reported as "above the usual price". Below that threshold the line states
53
+ * the median and claims nothing about this listing.
54
+ */
55
+ const delta = typeof price === "number" && price > 0 ? (price - band.median) / band.median : null;
56
+ const direction = delta === null || Math.abs(delta) < 0.05
57
+ ? null
58
+ : delta < 0
59
+ ? "below"
60
+ : "above";
61
+ return (_jsx(PricesOnly, { children: _jsxs(Text, { size: "xs", color: "muted", className: "mt-1", children: [`Sold here for a median of ${formatCurrency(band.median, currency)} — ${sales}${period}`, direction === "below" && ", so this one is under the usual price", direction === "above" && ", so this one is above the usual price"] }) }));
62
+ }
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import Link from "next/link.js";
3
3
  import { productRepository } from "../../../repositories/index.js";
4
+ import { priceIndexRepository } from "../../categories/repository/price-index.repository.js";
5
+ import { MarketPriceAnchor } from "./MarketPriceAnchor.js";
4
6
  const __P = {
5
7
  p3: "p-[var(--appkit-space-3)]",
6
8
  p5: "p-[var(--appkit-space-5)]",
7
9
  };
8
- const CLS_BREADCRUMB_LINK = "hover:text-primary-600 transition-colors";
9
10
  const ACTION_NOT_WIRED = "Action not wired";
10
11
  const CLS_STAR_FULL = "text-warning text-[length:var(--appkit-text-sm)]";
11
12
  const CLS_STAR_HALF = "text-warning text-[length:var(--appkit-text-sm)]";
@@ -23,8 +24,10 @@ const CLS_BUNDLE_ROW = "flex items-center justify-between gap-[var(--appkit-spac
23
24
  const CLS_BUNDLE_LABEL = "text-[length:var(--appkit-text-xs)] text-success dark:text-success font-medium uppercase tracking-wide mb-0.5";
24
25
  const CLS_BUNDLE_CTA = "flex-shrink-0 rounded-lg bg-success-solid hover:brightness-110 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-success-on-solid transition-colors";
25
26
  import { ROUTES } from "../../../next/routing/route-map.js";
27
+ import { ContextualTrail } from "../../categories/components/ContextualTrail.js";
28
+ import { productCrumbs, withTrailHead } from "../../categories/utils/breadcrumb-chain.js";
26
29
  import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
27
- import { Button, Container, Div, Heading, Main, Nav, PriceDisplay, RichText, Row, Section, Span, Stack, Text, Ul, Ol, Li, Dl, Dt, Dd, Icon, } from "../../../ui/index.js";
30
+ import { Button, Container, Div, Heading, Main, PriceDisplay, RichText, Row, Section, Span, Stack, Text, Ul, Ol, Li, Dl, Dt, Dd, Icon, } from "../../../ui/index.js";
28
31
  import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
29
32
  import { safeDisplayName } from "../../../security/index.js";
30
33
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
@@ -46,6 +49,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
46
49
  import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal.js";
47
50
  import { safeRead } from "../../../errors/safe-read.js";
48
51
  import { canMakeOffer } from "../../../_internal/shared/listing-types/capabilities.js";
52
+ import { MessageSellerButton } from "../../messages/components/MessageSellerButton.js";
49
53
  import { normalizeListingType } from "../utils/listing-type.js";
50
54
  import { DEFAULT_MIN_OFFER_PERCENT, resolveOfferBounds, } from "../../../_internal/shared/features/offers/config.js";
51
55
  // ---------------------------------------------------------------------------
@@ -134,6 +138,52 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
134
138
  const categoryNames = Array.isArray(p.categoryNames)
135
139
  ? p.categoryNames
136
140
  : categoryName ? [categoryName] : [];
141
+ /*
142
+ * I5b — the VISIBLE trail, from the same `productCrumbs` builder the route's
143
+ * JSON-LD uses.
144
+ *
145
+ * 🛑 This page rendered `Home / Products / <Category>` — three crumbs — while
146
+ * its own `BreadcrumbList` carried the full 8-level chain. The data was on
147
+ * the row the whole time; only the markup read it. Same builder, same
148
+ * inputs, so the two cannot describe different hierarchies.
149
+ */
150
+ const categoryIds = Array.isArray(p.categoryIds) ? p.categoryIds : [];
151
+ const categoryIdNames = Array.isArray(p.categoryIdNames)
152
+ ? p.categoryIdNames
153
+ : [];
154
+ const defaultTrail = withTrailHead([
155
+ ...productCrumbs({
156
+ categorySlugs,
157
+ categoryNames,
158
+ hrefFor: (s) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(s)),
159
+ }),
160
+ { name: title || String(p.slug ?? ""), url: String(ROUTES.PUBLIC.PRODUCT_DETAIL(String(p.slug ?? ""))) },
161
+ ], [
162
+ { name: "Home", url: String(ROUTES.HOME) },
163
+ { name: "Products", url: String(ROUTES.PUBLIC.PRODUCTS) },
164
+ ]);
165
+ /*
166
+ * One trail per assignment, default first. The alternates are a SINGLE-crumb
167
+ * chain each: a non-default assignment has no stored ancestor names on the
168
+ * product (only `categoryIdNames`), and inventing its ancestry from the
169
+ * default chain would describe a hierarchy that may not exist. A one-level
170
+ * trail is a true statement; a padded one is not.
171
+ */
172
+ const trails = categoryIds.length > 1
173
+ ? [
174
+ { categoryId: categoryIds[0] ?? "", crumbs: defaultTrail },
175
+ ...categoryIds.slice(1).map((id, i) => ({
176
+ categoryId: id,
177
+ crumbs: withTrailHead([
178
+ { name: categoryIdNames[i + 1] ?? id, url: String(ROUTES.PUBLIC.CATEGORY_DETAIL(id)) },
179
+ { name: title || String(p.slug ?? "") },
180
+ ], [
181
+ { name: "Home", url: String(ROUTES.HOME) },
182
+ { name: "Products", url: String(ROUTES.PUBLIC.PRODUCTS) },
183
+ ]),
184
+ })),
185
+ ]
186
+ : [{ categoryId: categoryIds[0] ?? primaryCategorySlug ?? "", crumbs: defaultTrail }];
137
187
  const subcategory = typeof p.subcategory === "string" ? p.subcategory : null;
138
188
  const brand = typeof p.brand === "string" ? p.brand : null;
139
189
  const brandSlug = typeof p.brandSlug === "string" ? p.brandSlug : null;
@@ -208,13 +258,29 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
208
258
  const storeId = typeof p.storeId === "string" ? p.storeId : null;
209
259
  // -- Fetch reviews + the shared 4-signal related-items computation + grouped listings in parallel --
210
260
  // Only page 1 of reviews is fetched here; <ReviewsListingPanel> pages the rest client-side.
211
- const [initialReviews, related, groups, siteSettings] = await Promise.all([
261
+ const [initialReviews, related, groups, siteSettings, priceBand] = await Promise.all([
212
262
  getReviewPageForProduct(product.id),
213
263
  computeRelatedItems(product),
214
264
  getGroupsWithItemsForProduct(product.id),
215
265
  safeRead(() => getSiteSettingsGlobal(), {
216
266
  route: "/products/[slug]", key: "product.siteSettings", fallback: null,
217
267
  }),
268
+ /*
269
+ * H2 — the realised-price band for this leaf, read INSIDE the existing
270
+ * `Promise.all` rather than beside it: the four reads above already run in
271
+ * parallel, so a fifth parallel read adds document reads but no latency,
272
+ * which is the argument Rule #6 cares about (~3 SEQUENTIAL round trips).
273
+ *
274
+ * One `findById` by leaf id — no query, no index. `null` when the leaf has
275
+ * never sold, which is the common case and renders nothing.
276
+ */
277
+ primaryCategorySlug
278
+ ? safeRead(() => priceIndexRepository.getForLeaf(primaryCategorySlug), {
279
+ route: "/products/[slug]",
280
+ key: "product.priceIndex",
281
+ fallback: null,
282
+ })
283
+ : Promise.resolve(null),
218
284
  ]);
219
285
  const { relatedItems, relatedByBrand, relatedByTags, relatedByStore } = related;
220
286
  return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: product.id, productType: "product", snapshot: {
@@ -223,7 +289,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
223
289
  price: product.price,
224
290
  storeId: product.storeId,
225
291
  storeName: product.storeName,
226
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCTS), className: CLS_BREADCRUMB_LINK, children: "Products" }), (primaryCategorySlug || category) && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(primaryCategorySlug ?? category)), className: CLS_BREADCRUMB_LINK, children: categoryNames[0] || categoryName || category })] })), subcategory && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { transform: "capitalize", children: subcategory })] }))] }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
292
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(ContextualTrail, { trails: trails, alternates: { ids: categoryIds, names: categoryIdNames } }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
227
293
  ? "Brand New"
228
294
  : condition === "like_new"
229
295
  ? "Like New"
@@ -253,7 +319,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
253
319
  categoryProductCount: (n, cat) => `${n} in ${cat}`,
254
320
  } }), productFeatures && features.length > 0 && (_jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures })), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this product" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i))) })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller }), typeof p.storeRating === "number" && p.storeRating > 0 && (_jsx(Row, { gap: "xs", align: "center", className: "mt-0.5", children: _jsxs(Text, { size: "xs", color: "muted", children: [_jsx(Span, { className: "text-warning", "aria-hidden": "true", children: "\u2605" }), " ", p.storeRating.toFixed(1), typeof p.storeReviewCount === "number" &&
255
321
  p.storeReviewCount > 0 &&
256
- ` · ${p.storeReviewCount} reviews`] }) }))] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderActions: () => (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [price !== null && (_jsxs(Div, { children: [_jsx(PriceDisplay, { amount: price, originalAmount: originalPrice ?? undefined, currency: currency, variant: "detail" }), inStock && effectiveStock !== null && effectiveStock <= 10 && (_jsxs(Text, { className: "mt-1 text-warning", size: "xs", children: ["Only ", effectiveStock, " left \u2014 order soon!"] }))] })), _jsxs(Stack, { gap: "sm", children: [renderPrimaryActions ? (renderPrimaryActions({
322
+ ` · ${p.storeReviewCount} reviews`] }) }))] }), _jsxs(Row, { gap: "xs", align: "center", className: "shrink-0", children: [storeId && _jsx(MessageSellerButton, { storeId: storeId }), storeHref && (_jsx(Link, { href: storeHref, className: "rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] })] }) }))] })), renderActions: () => (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [price !== null && (_jsxs(Div, { children: [_jsx(PriceDisplay, { amount: price, originalAmount: originalPrice ?? undefined, currency: currency, variant: "detail" }), _jsx(MarketPriceAnchor, { band: priceBand?.byListingType?.[normalizeListingType(p)] ?? null, windowDays: priceBand?.windowDays ?? null, currency: currency, price: price }), inStock && effectiveStock !== null && effectiveStock <= 3 && (_jsxs(Text, { className: "mt-1 text-warning", size: "xs", children: ["Only ", effectiveStock, " left \u2014 order soon!"] })), inStock && effectiveStock !== null && effectiveStock > 3 && effectiveStock <= 10 && (_jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [effectiveStock, " in stock"] }))] })), _jsxs(Stack, { gap: "sm", children: [renderPrimaryActions ? (renderPrimaryActions({
257
323
  productId: product.id,
258
324
  productSlug: product.slug ?? slug,
259
325
  productTitle: product.title,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * D5 — the cities that get their own local-SEO page.
3
+ *
4
+ * ## 🛑 Six, because six is what the data has
5
+ *
6
+ * Every entry here is a city a classified listing actually names in
7
+ * `classified.meetupArea.city`, measured from the seed rather than chosen from
8
+ * a list of big Indian cities. A page for a city with no listings is an empty
9
+ * page with a city in its `<title>` — which is the doorway-page pattern Google
10
+ * penalises, and the thing local SEO is most often got wrong by.
11
+ *
12
+ * 🛑 So the rule for adding one is: a city joins this list when listings name
13
+ * it, not in anticipation of listings naming it. The page still renders when a
14
+ * city's listings all sell — it says so in words rather than showing an empty
15
+ * grid, because "nobody is selling here this week" is true and useful, while a
16
+ * bare grid reads as broken.
17
+ *
18
+ * ## Why a constant rather than a query
19
+ *
20
+ * These routes are STATIC (`generateStaticParams`), which is the whole point:
21
+ * a city page that awaited `searchParams` or queried for its own slug list
22
+ * would be dynamic, and then every visitor and every crawler hit pays a render
23
+ * — on the surface most likely to be crawled widely (Root Cause #94). A
24
+ * `DISTINCT city` query is also not something Firestore offers, so deriving the
25
+ * list would mean scanning the collection at build time for six strings.
26
+ *
27
+ * @tag domain:products
28
+ * @tag layer:constant
29
+ * @tag access:isomorphic
30
+ */
31
+ export interface MeetupCity {
32
+ /** URL segment. Lower-case, hyphenated — the page's own identity. */
33
+ slug: string;
34
+ /** Exactly as stored in `classified.meetupArea.city`, which the query matches on. */
35
+ city: string;
36
+ /** State, for the page's own prose and its `<title>`. Never queried. */
37
+ state: string;
38
+ }
39
+ /**
40
+ * 🛑 `city` must match the STORED value byte-for-byte. Firestore `==` is
41
+ * byte-exact (Root Cause #33), so "New Delhi" and "new delhi" are two different
42
+ * cities to the query and one of them returns nothing with no error — which is
43
+ * exactly how a filter chip comes to look like it does nothing.
44
+ */
45
+ export declare const MEETUP_CITIES: readonly MeetupCity[];
46
+ /** The city for a URL segment, or `null` — which the route turns into a 404. */
47
+ export declare function meetupCityBySlug(slug: string): MeetupCity | null;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * D5 — the cities that get their own local-SEO page.
3
+ *
4
+ * ## 🛑 Six, because six is what the data has
5
+ *
6
+ * Every entry here is a city a classified listing actually names in
7
+ * `classified.meetupArea.city`, measured from the seed rather than chosen from
8
+ * a list of big Indian cities. A page for a city with no listings is an empty
9
+ * page with a city in its `<title>` — which is the doorway-page pattern Google
10
+ * penalises, and the thing local SEO is most often got wrong by.
11
+ *
12
+ * 🛑 So the rule for adding one is: a city joins this list when listings name
13
+ * it, not in anticipation of listings naming it. The page still renders when a
14
+ * city's listings all sell — it says so in words rather than showing an empty
15
+ * grid, because "nobody is selling here this week" is true and useful, while a
16
+ * bare grid reads as broken.
17
+ *
18
+ * ## Why a constant rather than a query
19
+ *
20
+ * These routes are STATIC (`generateStaticParams`), which is the whole point:
21
+ * a city page that awaited `searchParams` or queried for its own slug list
22
+ * would be dynamic, and then every visitor and every crawler hit pays a render
23
+ * — on the surface most likely to be crawled widely (Root Cause #94). A
24
+ * `DISTINCT city` query is also not something Firestore offers, so deriving the
25
+ * list would mean scanning the collection at build time for six strings.
26
+ *
27
+ * @tag domain:products
28
+ * @tag layer:constant
29
+ * @tag access:isomorphic
30
+ */
31
+ /**
32
+ * 🛑 `city` must match the STORED value byte-for-byte. Firestore `==` is
33
+ * byte-exact (Root Cause #33), so "New Delhi" and "new delhi" are two different
34
+ * cities to the query and one of them returns nothing with no error — which is
35
+ * exactly how a filter chip comes to look like it does nothing.
36
+ */
37
+ export const MEETUP_CITIES = [
38
+ { slug: "mumbai", city: "Mumbai", state: "Maharashtra" },
39
+ { slug: "bengaluru", city: "Bengaluru", state: "Karnataka" },
40
+ { slug: "pune", city: "Pune", state: "Maharashtra" },
41
+ { slug: "new-delhi", city: "New Delhi", state: "Delhi" },
42
+ { slug: "chennai", city: "Chennai", state: "Tamil Nadu" },
43
+ { slug: "hyderabad", city: "Hyderabad", state: "Telangana" },
44
+ ];
45
+ /** The city for a URL segment, or `null` — which the route turns into a 404. */
46
+ export function meetupCityBySlug(slug) {
47
+ const want = slug.trim().toLowerCase();
48
+ return MEETUP_CITIES.find((c) => c.slug === want) ?? null;
49
+ }