@mohasinac/appkit 4.47.0 → 4.48.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.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
- package/dist/_internal/server/features/messages/actions.d.ts +68 -0
- package/dist/_internal/server/features/messages/actions.js +249 -0
- package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
- package/dist/_internal/server/features/parts/buildable.js +159 -0
- package/dist/_internal/server/features/products/list-public.d.ts +17 -0
- package/dist/_internal/server/features/products/list-public.js +92 -5
- package/dist/_internal/server/features/products/og.d.ts +7 -0
- package/dist/_internal/server/features/products/og.js +38 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +6 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
- package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
- package/dist/_internal/shared/actions/action-registry.js +8 -0
- package/dist/_internal/shared/features/notifications/action-url.js +8 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +8 -0
- package/dist/constants/api-endpoints.d.ts +48 -0
- package/dist/constants/api-endpoints.js +30 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/about/components/GstExplainerView.d.ts +28 -0
- package/dist/features/about/components/GstExplainerView.js +59 -0
- package/dist/features/about/components/GuidesHubView.d.ts +28 -0
- package/dist/features/about/components/GuidesHubView.js +58 -0
- package/dist/features/about/components/PublicProfileView.js +5 -0
- package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
- package/dist/features/about/components/SpotAFakeView.js +49 -0
- package/dist/features/admin/actions/notification-actions.js +4 -0
- package/dist/features/admin/schemas/firestore.d.ts +13 -3
- package/dist/features/admin/schemas/firestore.js +8 -0
- package/dist/features/blog/repository/blog.repository.js +21 -7
- package/dist/features/categories/api/route.js +41 -1
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
- package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
- package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
- package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
- package/dist/features/categories/components/ContextualTrail.js +81 -0
- package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
- package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +21 -0
- package/dist/features/categories/repository/categories.repository.js +36 -3
- package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
- package/dist/features/email/notification-templates.js +4 -0
- package/dist/features/events/repository/events.repository.js +10 -3
- package/dist/features/faq/constants/read-caps.d.ts +29 -0
- package/dist/features/faq/constants/read-caps.js +29 -0
- package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
- package/dist/features/faq/repository/faqs.repository.js +19 -16
- package/dist/features/homepage/components/AdSlot.js +10 -4
- package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
- package/dist/features/homepage/hooks/useActiveAd.js +42 -2
- package/dist/features/homepage/lib/section-renderer.js +12 -2
- package/dist/features/layout/AppLayoutShell.d.ts +4 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/TitleBarLayout.d.ts +4 -1
- package/dist/features/layout/TitleBarLayout.js +8 -4
- package/dist/features/media/AvatarUpload.js +8 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.js +3 -1
- package/dist/features/media/MediaLightbox.js +4 -1
- package/dist/features/media/MediaPickerModal.js +6 -1
- package/dist/features/media/MediaVideo.js +3 -1
- package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
- package/dist/features/messages/components/ConversationThreadView.js +75 -0
- package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
- package/dist/features/messages/components/ConversationsInboxView.js +39 -0
- package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
- package/dist/features/messages/components/MessageSellerButton.js +33 -0
- package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
- package/dist/features/messages/hooks/useConversationChannel.js +89 -0
- package/dist/features/messages/hooks/useMessages.d.ts +347 -0
- package/dist/features/messages/hooks/useMessages.js +113 -0
- package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
- package/dist/features/messages/repository/conversations.repository.js +173 -0
- package/dist/features/messages/repository/messages.repository.d.ts +42 -0
- package/dist/features/messages/repository/messages.repository.js +142 -0
- package/dist/features/messages/schemas/firestore.d.ts +99 -0
- package/dist/features/messages/schemas/firestore.js +46 -0
- package/dist/features/orders/repository/orders.repository.js +21 -7
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
- package/dist/features/products/components/CityCollectiblesView.js +82 -0
- package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
- package/dist/features/products/components/MarketPriceAnchor.js +62 -0
- package/dist/features/products/components/ProductDetailPageView.js +71 -5
- package/dist/features/products/constants/meetup-cities.d.ts +47 -0
- package/dist/features/products/constants/meetup-cities.js +49 -0
- package/dist/features/products/repository/category-assignment.d.ts +82 -11
- package/dist/features/products/repository/category-assignment.js +93 -12
- package/dist/features/products/repository/products.repository.d.ts +12 -1
- package/dist/features/products/repository/products.repository.js +66 -34
- package/dist/features/products/schemas/firestore.d.ts +24 -2
- package/dist/features/products/schemas/firestore.js +13 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/repository/coupons.repository.js +11 -4
- package/dist/features/reviews/repository/reviews.repository.js +11 -4
- package/dist/features/scams/repository/scammer.repository.js +21 -7
- package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
- package/dist/features/seller/components/SellerProductShell.js +2 -2
- package/dist/features/seller/repository/offer.repository.js +21 -6
- package/dist/features/seller/schemas/product-form.d.ts +3 -3
- package/dist/features/seller/schemas/product-form.js +1 -1
- package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
- package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
- package/dist/features/server-errors/schemas/firestore.js +2 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
- package/dist/features/store-extensions/schemas/firestore.js +4 -0
- package/dist/features/stores/repository/store.repository.js +11 -3
- package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/dist/next/routing/route-map.d.ts +14 -0
- package/dist/next/routing/route-map.js +26 -0
- package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
- package/dist/providers/db-firebase/rtdb-paths.js +8 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +17 -0
- package/dist/security/pii-schemas.js +15 -7
- package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
- package/dist/seed/_helpers/category-content-generated.js +23 -7
- package/dist/seed/_helpers/category-models.d.ts +1 -1
- package/dist/seed/_helpers/category-models.js +25 -1
- package/dist/seed/_helpers/category-names.d.ts +54 -0
- package/dist/seed/_helpers/category-names.js +118 -0
- package/dist/seed/_helpers/character-features.d.ts +1 -1
- package/dist/seed/_helpers/character-features.js +5 -4
- package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
- package/dist/seed/categories-seed-data.js +55 -1
- package/dist/seed/grouped-listings-seed-data.js +2 -2
- package/dist/seed/products-art-seed-data.js +2 -1
- package/dist/seed/products-auctions-seed-data.js +42 -22
- package/dist/seed/products-classifieds-seed-data.js +21 -3
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.js +26 -11
- package/dist/seed/products-prize-draws-seed-data.js +8 -1
- package/dist/seed/products-standard-seed-data.js +26 -25
- package/dist/seed/products-stickers-seed-data.js +12 -11
- package/dist/server.d.ts +8 -0
- package/dist/server.js +15 -0
- package/dist/styles.css +113 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/sunset.js +17 -0
- package/dist/tokens/tokens.css +5 -0
- package/dist/ui/components/Breadcrumb.d.ts +8 -1
- package/dist/ui/components/Breadcrumb.js +2 -2
- package/dist/ui/components/Breadcrumb.style.css +37 -0
- package/dist/ui/components/Details.d.ts +11 -1
- package/dist/ui/components/Details.js +2 -2
- package/dist/ui/components/ImageMosaic.d.ts +43 -0
- package/dist/ui/components/ImageMosaic.js +46 -0
- package/dist/ui/components/ImageMosaic.style.css +69 -0
- package/dist/ui/components/SplitTile.d.ts +34 -0
- package/dist/ui/components/SplitTile.js +28 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/utils/search-txt-query.d.ts +77 -0
- package/dist/utils/search-txt-query.js +50 -28
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildOrderSearchTxt } from "../../../utils/search-txt-builders.js";
|
|
2
|
-
import { planSearchTxt,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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: [
|
|
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: "
|
|
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
|
+
}
|