@mohasinac/appkit 4.53.1 → 4.54.0-next.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 +5 -5
- package/dist/_internal/client/theme/ThemeProvider.js +15 -1
- package/dist/_internal/server/features/auth/capabilities.js +7 -5
- package/dist/_internal/server/features/categories/price-guidance-resolve.d.ts +66 -0
- package/dist/_internal/server/features/categories/price-guidance-resolve.js +138 -0
- package/dist/_internal/server/features/checkout/supplier-identity.js +28 -6
- package/dist/_internal/server/features/price-index/market.d.ts +57 -0
- package/dist/_internal/server/features/price-index/market.js +120 -0
- package/dist/_internal/server/features/products/list-public.d.ts +12 -0
- package/dist/_internal/server/features/products/list-public.js +10 -1
- package/dist/_internal/server/features/products/listing-tab-counts.d.ts +25 -1
- package/dist/_internal/server/features/products/listing-tab-counts.js +184 -10
- package/dist/_internal/server/features/search/index-build/sources.js +43 -2
- package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
- package/dist/_internal/server/features/stores/tabs.js +5 -3
- package/dist/_internal/server/jobs/core/jobRunners.js +0 -10
- package/dist/_internal/server/jobs/core/onOrderCreate.js +10 -104
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +128 -0
- package/dist/_internal/shared/features/categories/bundle-copy.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/supplier-of-record.d.ts +56 -0
- package/dist/_internal/shared/features/site-settings/supplier-of-record.js +57 -0
- package/dist/_internal/shared/listing-types/tiers.d.ts +94 -0
- package/dist/_internal/shared/listing-types/tiers.js +150 -0
- package/dist/_internal/shared/search/match.js +28 -2
- package/dist/cli/index.js +0 -2
- package/dist/client.d.ts +21 -2
- package/dist/client.js +23 -4
- package/dist/constants/api-endpoints.d.ts +28 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/account/components/UserOrdersView.js +26 -7
- package/dist/features/addresses/utils/postal-autofill.d.ts +39 -0
- package/dist/features/addresses/utils/postal-autofill.js +54 -0
- package/dist/features/admin/components/AdminStoreEditorView.js +73 -34
- package/dist/features/admin/components/OwnerQuickActions.d.ts +29 -0
- package/dist/features/admin/components/OwnerQuickActions.js +142 -0
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +1 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +4 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
- package/dist/features/auctions/components/MarketplaceAuctionCard.d.ts +11 -1
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +9 -2
- package/dist/features/auth/permissions/constants.d.ts +29 -2
- package/dist/features/auth/permissions/constants.js +68 -1
- package/dist/features/blog/components/BlogFeaturedCard.d.ts +11 -1
- package/dist/features/blog/components/BlogFeaturedCard.js +9 -2
- package/dist/features/cart/components/CartDrawer.js +3 -2
- package/dist/features/cart/components/CartGroupLineRow.js +2 -1
- package/dist/features/cart/hooks/useAddToCart.js +10 -5
- package/dist/features/cart/utils/pending-ops.d.ts +11 -1
- package/dist/features/cart/utils/pending-ops.js +14 -2
- package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
- package/dist/features/categories/components/BrandDetailPageView.js +15 -6
- package/dist/features/categories/components/BrandDetailTabs.d.ts +7 -1
- package/dist/features/categories/components/BrandDetailTabs.js +18 -3
- package/dist/features/categories/components/BundleCollage.js +3 -2
- package/dist/features/categories/components/BundleItemsPicker.js +3 -4
- package/dist/features/categories/components/CategoryDetailPageView.js +19 -8
- package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
- package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
- package/dist/features/categories/components/CategoryGrid.d.ts +11 -1
- package/dist/features/categories/components/CategoryGrid.js +9 -2
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +10 -5
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +2 -88
- package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
- package/dist/features/events/components/EventCard.d.ts +11 -1
- package/dist/features/events/components/EventCard.js +9 -2
- package/dist/features/layout/AppLayoutShell.js +2 -1
- package/dist/features/layout/BottomActions.d.ts +1 -0
- package/dist/features/layout/BottomActions.js +330 -0
- package/dist/features/layout/BottomChrome.d.ts +45 -0
- package/dist/features/layout/BottomChrome.js +5 -17
- package/dist/features/layout/SideActionDock.js +36 -13
- package/dist/features/layout/index.d.ts +2 -7
- package/dist/features/layout/index.js +7 -7
- package/dist/features/layout/useIsDesktop.d.ts +36 -0
- package/dist/features/layout/useIsDesktop.js +48 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
- package/dist/features/media/messages/en.json +67 -67
- package/dist/features/orders/components/YourPurchaseStrip.d.ts +24 -0
- package/dist/features/orders/components/YourPurchaseStrip.js +77 -0
- package/dist/features/orders/components/index.d.ts +2 -1
- package/dist/features/orders/components/index.js +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +18 -0
- package/dist/features/orders/utils/order-announcement-message.js +16 -0
- package/dist/features/orders/utils/payment-proof-message.d.ts +20 -0
- package/dist/features/orders/utils/payment-proof-message.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +227 -0
- package/dist/features/parts/schemas/firestore.js +6 -6
- package/dist/features/pre-orders/components/MarketplacePreorderCard.d.ts +11 -1
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +10 -3
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +8 -7
- package/dist/features/price-index/components/PriceIndexToolView.d.ts +21 -0
- package/dist/features/price-index/components/PriceIndexToolView.js +230 -27
- package/dist/features/price-index/lookup-target.d.ts +48 -0
- package/dist/features/price-index/lookup-target.js +107 -0
- package/dist/features/products/api/[id]/route.js +8 -1
- package/dist/features/products/api/route.js +16 -4
- package/dist/features/products/components/AvailabilityScopeHint.d.ts +22 -0
- package/dist/features/products/components/AvailabilityScopeHint.js +27 -0
- package/dist/features/products/components/CompareOverlay.d.ts +2 -1
- package/dist/features/products/components/CompareOverlay.js +2 -1
- package/dist/features/products/components/GroupSettingsPanel.js +2 -1
- package/dist/features/products/components/InteractiveProductCard.d.ts +7 -1
- package/dist/features/products/components/InteractiveProductCard.js +16 -4
- package/dist/features/products/components/MarketplaceBundleCard.d.ts +11 -1
- package/dist/features/products/components/MarketplaceBundleCard.js +9 -2
- package/dist/features/products/components/MarketplacePrizeDrawCard.d.ts +11 -1
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +9 -2
- package/dist/features/products/components/ProductDetailPageView.js +34 -4
- package/dist/features/products/components/ProductGrid.d.ts +14 -1
- package/dist/features/products/components/ProductGrid.js +22 -5
- package/dist/features/products/components/ProductsIndexListing.js +2 -2
- package/dist/features/products/components/SublistingCarouselSection.js +2 -1
- package/dist/features/products/repository/products.repository.d.ts +1 -0
- package/dist/features/products/repository/products.repository.js +5 -1
- package/dist/features/products/utils/group-member-image.d.ts +9 -2
- package/dist/features/products/utils/group-member-image.js +2 -1
- package/dist/features/reviews/api/[id]/route.js +83 -23
- package/dist/features/reviews/components/PurchaseReviewAffordance.d.ts +6 -0
- package/dist/features/reviews/components/PurchaseReviewAffordance.js +42 -0
- package/dist/features/reviews/components/ReviewFormDrawer.d.ts +34 -0
- package/dist/features/reviews/components/ReviewFormDrawer.js +127 -0
- package/dist/features/reviews/components/index.d.ts +4 -0
- package/dist/features/reviews/components/index.js +2 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +49 -3
- package/dist/features/seller/components/SellerPayoutSettingsView.js +30 -2
- package/dist/features/seller/components/SellerProductShell.d.ts +17 -1
- package/dist/features/seller/components/SellerProductsView.js +3 -2
- package/dist/features/seller/hooks/useStoreListingEntitlements.d.ts +8 -0
- package/dist/features/seller/hooks/useStoreListingEntitlements.js +70 -0
- package/dist/features/stores/components/InteractiveStoreCard.d.ts +11 -1
- package/dist/features/stores/components/InteractiveStoreCard.js +9 -2
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -1
- package/dist/features/stores/components/index.d.ts +0 -2
- package/dist/features/stores/components/index.js +0 -1
- package/dist/features/stores/schemas/index.d.ts +1 -1
- package/dist/features/stores/schemas/index.js +10 -1
- package/dist/features/tester/seed-data/authored/content-discovery__authenticity-checker.js +114 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +415 -0
- package/dist/index.d.ts +8 -27
- package/dist/index.js +14 -35
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +98 -0
- package/dist/providers/shipping-shiprocket/index.js +353 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/security/csp.d.ts +16 -1
- package/dist/security/csp.js +5 -3
- package/dist/seed/_helpers/category-forest.js +27 -0
- package/dist/seed/_helpers/category-parts-glossary-map.js +1816 -86
- package/dist/seed/_helpers/category-parts.d.ts +1 -1
- package/dist/seed/_helpers/category-parts.js +13412 -3020
- package/dist/seed/categories-seed-data.js +48 -6
- package/dist/seed/homepage-sections-seed-data.js +41 -28
- package/dist/seed/model-composition-seed-data.js +6 -8
- package/dist/seed/part-glossary/chunk0.d.ts +9 -0
- package/dist/seed/part-glossary/chunk0.js +35044 -0
- package/dist/seed/part-glossary/chunk1.d.ts +9 -0
- package/dist/seed/part-glossary/chunk1.js +39955 -0
- package/dist/seed/part-glossary/chunk2.d.ts +9 -0
- package/dist/seed/part-glossary/chunk2.js +50335 -0
- package/dist/seed/part-glossary/chunk3.d.ts +9 -0
- package/dist/seed/part-glossary/chunk3.js +52386 -0
- package/dist/seed/part-glossary/chunk4.d.ts +9 -0
- package/dist/seed/part-glossary/chunk4.js +50549 -0
- package/dist/seed/part-glossary-seed-data.d.ts +1 -1
- package/dist/seed/part-glossary-seed-data.js +23 -47483
- package/dist/seed/products-beyblade-x-seed-data.js +36 -1
- package/dist/server.d.ts +6 -8
- package/dist/server.js +16 -20
- package/dist/styles.css +516 -358
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +3 -1
- package/dist/tokens/themes/index.js +11 -1
- package/dist/tokens/themes/technical-dark.d.ts +30 -0
- package/dist/tokens/themes/technical-dark.js +103 -0
- package/dist/tokens/themes/technical-light.d.ts +22 -0
- package/dist/tokens/themes/technical-light.js +89 -0
- package/dist/tokens/tokens.css +155 -0
- package/dist/ui/components/GatedPrice.js +30 -3
- package/dist/ui/components/GatedPrice.style.css +22 -19
- package/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +133 -27
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.d.ts +0 -27
- package/dist/_internal/server/jobs/core/whatsappCatalogImport.js +0 -172
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +0 -35
- package/dist/_internal/server/jobs/core/whatsappNotify.js +0 -140
- package/dist/features/admin/components/AdminWhatsAppGuideView.d.ts +0 -2
- package/dist/features/admin/components/AdminWhatsAppGuideView.js +0 -35
- package/dist/features/orders/components/MarketplaceOrderCard.d.ts +0 -36
- package/dist/features/orders/components/MarketplaceOrderCard.js +0 -57
- package/dist/features/stores/components/StoreWhatsAppGuideView.d.ts +0 -3
- package/dist/features/stores/components/StoreWhatsAppGuideView.js +0 -12
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +0 -12
- package/dist/features/whatsapp-bot/api/send-status/route.js +0 -38
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +0 -12
- package/dist/features/whatsapp-bot/api/webhook/route.js +0 -43
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +0 -6
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -270
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +0 -9
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +0 -7
- package/dist/features/whatsapp-bot/components/index.d.ts +0 -3
- package/dist/features/whatsapp-bot/components/index.js +0 -2
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.d.ts +0 -14
- package/dist/features/whatsapp-bot/helpers/payment-proof-message.js +0 -17
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +0 -52
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +0 -283
- package/dist/features/whatsapp-bot/index.d.ts +0 -3
- package/dist/features/whatsapp-bot/index.js +0 -3
- package/dist/features/whatsapp-bot/manifest.d.ts +0 -2
- package/dist/features/whatsapp-bot/manifest.js +0 -13
- package/dist/features/whatsapp-bot/messages/en.json +0 -7
- package/dist/features/whatsapp-bot/server.d.ts +0 -7
- package/dist/features/whatsapp-bot/server.js +0 -7
- package/dist/features/whatsapp-bot/types/index.d.ts +0 -126
- package/dist/features/whatsapp-bot/types/index.js +0 -1
|
@@ -8,13 +8,20 @@ import { Badge } from "../../../ui/components/Badge.js";
|
|
|
8
8
|
import { Button } from "../../../ui/components/Button.js";
|
|
9
9
|
import { Form } from "../../../ui/components/Form.js";
|
|
10
10
|
import { FieldInput } from "../../../ui/forms/FieldInput.js";
|
|
11
|
+
import { PaginatedSelect } from "../../../ui/components/PaginatedSelect.js";
|
|
12
|
+
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
11
13
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
12
14
|
import { TOOL_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
13
15
|
import { apiClient } from "../../../http/ApiClient.js";
|
|
14
16
|
import { useSiteSearch } from "../../search/hooks/useSiteSearch.js";
|
|
17
|
+
import { useSearchIndex } from "../../search/hooks/useSearchIndex.js";
|
|
18
|
+
import { searchIndex } from "../../../_internal/shared/search/match.js";
|
|
19
|
+
import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
|
|
20
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
15
21
|
import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
16
22
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
17
23
|
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
24
|
+
import { PRICE_LOOKUP_INDEX_TYPES, priceLookupTargetFor } from "../lookup-target.js";
|
|
18
25
|
/** Human labels. Falls back to the raw value so a new condition still renders. */
|
|
19
26
|
const CONDITION_LABEL = {
|
|
20
27
|
new: "New",
|
|
@@ -47,6 +54,14 @@ const __O = {
|
|
|
47
54
|
xAuto: "overflow-x-auto",
|
|
48
55
|
hidden: "overflow-hidden",
|
|
49
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* How many options the dropdown offers.
|
|
59
|
+
*
|
|
60
|
+
* Larger than `PRICE_INDEX_SEARCH_LIMIT`'s 8 because this is a PICK list rather
|
|
61
|
+
* than a result set — the cost of an extra row is a line of text, not a
|
|
62
|
+
* Firestore document, so the bound that matters is what a person will read.
|
|
63
|
+
*/
|
|
64
|
+
const DROPDOWN_LIMIT = 20;
|
|
50
65
|
const money = (v) => formatCurrency(v, "INR");
|
|
51
66
|
function relativeDay(iso) {
|
|
52
67
|
if (!iso)
|
|
@@ -79,22 +94,71 @@ function BandCard({ band, threshold }) {
|
|
|
79
94
|
? ` · via ${band.lastSold.via}`
|
|
80
95
|
: ""] }))] })) : (_jsx(LowConfidence, { sampleSize: band.sampleSize, threshold: threshold })), band.byCondition && band.byCondition.length > 0 && (_jsx(ConditionTable, { rows: band.byCondition, threshold: threshold }))] }));
|
|
81
96
|
}
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The competitor-crawl band: what this thing is typically ASKED for.
|
|
99
|
+
*
|
|
100
|
+
* ## 🛑 Side by side with the sold band, never averaged with it
|
|
101
|
+
*
|
|
102
|
+
* `price-bands.md` states the rule this component exists to honour: "They are
|
|
103
|
+
* rendered side by side and never averaged. The gap between them is the most
|
|
104
|
+
* useful thing we can tell a seller." An ask and a sale are different events,
|
|
105
|
+
* and a blended figure would be a statistic describing neither.
|
|
106
|
+
*
|
|
107
|
+
* ## 🛑 "Sold out" is not "sold"
|
|
108
|
+
*
|
|
109
|
+
* 76–93% of the crawled catalogues are sold out, and a sold-out row proves the
|
|
110
|
+
* item is GONE, not that it changed hands at that price. So `soldOutShare` is
|
|
111
|
+
* rendered as prose rather than dropped — without it, a reader takes the median
|
|
112
|
+
* for a clearing price, which is the single wrong conclusion this data invites.
|
|
113
|
+
*
|
|
114
|
+
* ## 🛑 An inherited band says whose band it is
|
|
115
|
+
*
|
|
116
|
+
* `MARKET_BANDS` is keyed on era and part-group leaves, never on the ~216
|
|
117
|
+
* named-model leaves — the crawl measured a generation, not a bey. So a model
|
|
118
|
+
* lookup resolves its era's band, and the card must attribute it: "typical for
|
|
119
|
+
* Burst DB" is true, "typical for Rage Longinus" is not.
|
|
120
|
+
*/
|
|
121
|
+
function MarketCard({ market }) {
|
|
122
|
+
const hasRange = market.p25 !== undefined && market.p75 !== undefined;
|
|
123
|
+
const soldOutPct = market.soldOutShare === undefined ? null : Math.round(market.soldOutShare * 100);
|
|
124
|
+
return (_jsxs(Stack, { gap: "sm", padding: "sm", rounded: "lg", border: "default", className: "min-w-0", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, className: "min-w-0", children: [_jsx(Badge, { variant: "secondary", children: "Typical asking price" }), market.sampleSize !== undefined && (_jsxs(Text, { size: "xs", color: "muted", children: [market.sampleSize, " ", market.sampleSize === 1 ? "listing" : "listings"] }))] }), _jsxs(Row, { gap: "lg", wrap: true, className: "min-w-0", children: [hasRange && _jsx(Figure, { label: "Usual low", value: money(market.p25) }), _jsx(Figure, { label: "Typical ask", value: money(market.median) }), hasRange && _jsx(Figure, { label: "Usual high", value: money(market.p75) })] }), market.inherited && (_jsxs(Text, { size: "sm", color: "muted", children: ["Measured for", " ", _jsx(Span, { weight: "semibold", children: market.viaName ?? market.viaCategoryId }), " as a whole \u2014 the crawl priced the generation, not this exact item."] })), soldOutPct !== null && (_jsxs(Text, { size: "sm", color: "muted", children: ["These are asking prices from other shops, and about ", soldOutPct, "% of listings at this level are sold out \u2014 so an ask is not proof anything changed hands at it."] })), _jsxs(Text, { size: "xs", color: "muted", className: "min-w-0 break-words", children: [market.source ? `Source: ${market.source}. ` : "", "Tax-inclusive", market.asOf
|
|
125
|
+
? ` · measured ${new Date(market.asOf).toLocaleDateString("en-IN")}`
|
|
126
|
+
: ""] })] }));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* What a signed-out visitor sees where the market band would be.
|
|
130
|
+
*
|
|
131
|
+
* 🛑 This is NOT the guest price gate. The sold band above it is public by
|
|
132
|
+
* decision (see this file's header). The market band is withheld for a
|
|
133
|
+
* different reason, already settled at
|
|
134
|
+
* `categories/api/[id]/price-guidance/route.ts`: it is competitor-derived, and
|
|
135
|
+
* publishing it on an unauthenticated, cacheable surface is the "neighbouring
|
|
136
|
+
* surface publishes the range" leak. The copy therefore says what is behind
|
|
137
|
+
* sign-in and why it is worth having, rather than implying no data exists.
|
|
138
|
+
*/
|
|
139
|
+
function MarketSignInPrompt() {
|
|
140
|
+
return (_jsxs(Stack, { gap: "xs", padding: "sm", rounded: "lg", surface: "muted", className: "min-w-0", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Asking prices elsewhere" }), _jsxs(Text, { size: "sm", color: "muted", children: ["We measured what other Indian shops ask for this, from a crawl of fifteen catalogues.", " ", _jsx(TextLink, { href: String(ROUTES.AUTH.LOGIN), children: "Sign in" }), " to see the range."] })] }));
|
|
141
|
+
}
|
|
142
|
+
function ResultCard({ row, threshold, market, canSeeMarket, }) {
|
|
143
|
+
return (_jsxs(Stack, { gap: "md", padding: "card", rounded: "lg", surface: "card", border: "subtle", className: "min-w-0", children: [_jsxs(Row, { gap: "md", align: "start", className: "min-w-0", children: [row.coverImage && (_jsx(Div, { rounded: "lg", className: `w-16 h-16 flex-shrink-0 ${__O.hidden}`, children: _jsx(MediaImage, { src: row.coverImage, alt: row.name, size: "thumbnail" }) })), _jsxs(Stack, { gap: "none", className: "flex-1 min-w-0", children: [_jsx(Heading, { level: 3, children: row.name }), row.path && (_jsx(Text, { size: "xs", color: "muted", className: "min-w-0 break-words", children: row.path }))] })] }), (!row.bands || row.bands.length === 0) &&
|
|
144
|
+
(market ? _jsx(MarketCard, { market: market }) : canSeeMarket ? null : _jsx(MarketSignInPrompt, {})), row.bands && row.bands.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Stack, { gap: "sm", className: "min-w-0", children: row.bands.map((b) => (_jsx(BandCard, { band: b, threshold: threshold }, b.listingType))) }), market ? _jsx(MarketCard, { market: market }) : canSeeMarket ? null : _jsx(MarketSignInPrompt, {}), _jsxs(Text, { size: "xs", color: "muted", children: ["Completed sales on LetItRip", row.windowDays ? ` over the last ${row.windowDays} days` : "", row.computedAt ? ` · as of ${new Date(row.computedAt).toLocaleDateString("en-IN")}` : ""] })] })) : (
|
|
84
145
|
/*
|
|
85
146
|
* 🛑 "No sales recorded yet" — never ₹0, and never a blank card. The
|
|
86
147
|
* absence of a band means nothing has sold here, which is a fact about
|
|
87
148
|
* our marketplace and not a failure of the lookup.
|
|
149
|
+
*
|
|
150
|
+
* 🛑 The wording is DIFFERENT when a market band is showing above it.
|
|
151
|
+
* "Prices appear once something sells" was written for a card that had
|
|
152
|
+
* nothing else on it; printed under a real asking-price band it reads
|
|
153
|
+
* as though the band were not a price, which is the opposite of what
|
|
154
|
+
* this card now says.
|
|
88
155
|
*/
|
|
89
|
-
_jsx(Text, { size: "sm", color: "muted", children:
|
|
156
|
+
_jsx(Text, { size: "sm", color: "muted", children: market
|
|
157
|
+
? "Nothing has sold here on LetItRip yet, so the figures above are asking prices elsewhere rather than our own sales."
|
|
158
|
+
: "No completed sales recorded here yet. Prices appear once something sells on LetItRip." }))] }));
|
|
90
159
|
}
|
|
91
160
|
/** Stable identity — a `useMemo` dependency inside `useSiteSearch`. */
|
|
92
161
|
const identityLabel = (typeKey) => typeKey;
|
|
93
|
-
/** `/categories/{slug}` -> `{slug}`. Category documents are pure slugs (id === slug). */
|
|
94
|
-
function categoryIdFromHref(href) {
|
|
95
|
-
const m = /^\/categories\/([^/?#]+)/.exec(href);
|
|
96
|
-
return m ? m[1] : null;
|
|
97
|
-
}
|
|
98
162
|
export function PriceIndexToolView({ initialQuery = "", initialData = null, searchIndexEnabled, }) {
|
|
99
163
|
const [draft, setDraft] = useState(initialQuery);
|
|
100
164
|
/**
|
|
@@ -105,24 +169,108 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
|
|
|
105
169
|
* keystroke against a route that holds a Firestore query plus a multi-get —
|
|
106
170
|
* which is Root Cause #94's recurring server bill, and `audit-client-poll-cost`
|
|
107
171
|
* exists because of it. The query key only moves when somebody submits.
|
|
172
|
+
*
|
|
173
|
+
* 🛑 Still here after the dropdown landed, because it drives the FALLBACK
|
|
174
|
+
* path: the dropdown's options come from the local search artifact, so when
|
|
175
|
+
* that artifact is disabled or fails to load there is nothing to pick from
|
|
176
|
+
* and the tool must still work. Deleting this would have made a kill-switch
|
|
177
|
+
* flip turn the whole page into an empty combobox.
|
|
108
178
|
*/
|
|
109
179
|
const [term, setTerm] = useState(initialQuery.trim());
|
|
180
|
+
/**
|
|
181
|
+
* What the dropdown picked: the category id to price, plus its label.
|
|
182
|
+
*
|
|
183
|
+
* 🛑 The LABEL is kept alongside the id, not looked up again. The option list
|
|
184
|
+
* is rebuilt per keystroke inside `loadOptions`, so by the time results
|
|
185
|
+
* arrive the option that produced the selection may no longer be in the list
|
|
186
|
+
* — and `PaginatedSelect` renders the trigger from the value it was given.
|
|
187
|
+
* Without this the chosen item's name would vanish from the control the
|
|
188
|
+
* moment the query that found it changed.
|
|
189
|
+
*/
|
|
190
|
+
const [picked, setPicked] = useState(null);
|
|
191
|
+
/** Signed in? Decides whether the market half is requested at all. */
|
|
192
|
+
const session = useOptionalSession();
|
|
193
|
+
const canSeeMarket = Boolean(session?.user?.uid);
|
|
110
194
|
/*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
|
|
195
|
+
* The artifact, for the dropdown. `active` is true as soon as the control is
|
|
196
|
+
* on the page: unlike the old submit-driven flow there is no "intent"
|
|
197
|
+
* threshold to wait for — a combobox the user has just clicked must have
|
|
198
|
+
* options, and the artifact is a one-time ~420 KB load with a module-scope
|
|
199
|
+
* singleton behind it, not a per-keystroke cost.
|
|
200
|
+
*/
|
|
201
|
+
const dropdownIndex = useSearchIndex({
|
|
202
|
+
active: true,
|
|
203
|
+
indexEnabled: searchIndexEnabled,
|
|
204
|
+
});
|
|
205
|
+
/**
|
|
206
|
+
* Options for the dropdown, resolved LOCALLY on every keystroke.
|
|
117
207
|
*
|
|
118
|
-
* `
|
|
119
|
-
*
|
|
120
|
-
*
|
|
208
|
+
* 🛑 `async` only to satisfy `loadOptions`' signature — nothing here awaits.
|
|
209
|
+
* `searchIndex` is a linear scan over ~3,700 prepared records, measured at
|
|
210
|
+
* 0.76 ms by `verify-search-local.mjs`, so there is no network round trip to
|
|
211
|
+
* debounce and no Firestore read to bill. This is the whole reason the
|
|
212
|
+
* dropdown can be a dropdown.
|
|
213
|
+
*
|
|
214
|
+
* 🛑 Hits that cannot be priced are DROPPED, not shown disabled. An option
|
|
215
|
+
* that selects to a card reading "no data" is worse than an absent one — it
|
|
216
|
+
* looks like the tool failed rather than like the item is not priceable. See
|
|
217
|
+
* `priceLookupTargetFor` for which types resolve and why.
|
|
218
|
+
*/
|
|
219
|
+
const loadOptions = useCallback(async (query, page) => {
|
|
220
|
+
const idx = dropdownIndex.index;
|
|
221
|
+
if (!idx || !query.trim())
|
|
222
|
+
return { items: [], hasMore: false };
|
|
223
|
+
const hits = searchIndex(idx.artifact, idx.prepared, query, {
|
|
224
|
+
types: PRICE_LOOKUP_INDEX_TYPES,
|
|
225
|
+
limit: DROPDOWN_LIMIT,
|
|
226
|
+
});
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
const items = [];
|
|
229
|
+
for (const hit of hits) {
|
|
230
|
+
const target = priceLookupTargetFor(hit.href);
|
|
231
|
+
if (!target || seen.has(target))
|
|
232
|
+
continue;
|
|
233
|
+
seen.add(target);
|
|
234
|
+
items.push({
|
|
235
|
+
value: target,
|
|
236
|
+
/*
|
|
237
|
+
* The type and the detail both earn their place: `detail` is the
|
|
238
|
+
* taxonomy path for a category and the classification for a part,
|
|
239
|
+
* which is how a reader tells three results named "105" apart.
|
|
240
|
+
*/
|
|
241
|
+
label: `${hit.title}${hit.detail ? ` — ${hit.detail}` : ""}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// One page: the matcher already ranked and capped. Paging a ranked list
|
|
245
|
+
// would invite scrolling past the answer rather than refining the query.
|
|
246
|
+
return { items, hasMore: false, nextPage: page };
|
|
247
|
+
}, [dropdownIndex.index]);
|
|
248
|
+
/**
|
|
249
|
+
* 🛑 The dropdown is only usable with an artifact behind it. On `disabled` or
|
|
250
|
+
* `failed` the page falls back to the original text box, which hits the
|
|
251
|
+
* server-side `searchTxt` path. Reported as a state rather than silently
|
|
252
|
+
* degrading, because an empty combobox is indistinguishable from an empty
|
|
253
|
+
* catalogue.
|
|
254
|
+
*/
|
|
255
|
+
const dropdownUsable = dropdownIndex.status === "ready" || dropdownIndex.status === "loading" ||
|
|
256
|
+
dropdownIndex.status === "idle";
|
|
257
|
+
/*
|
|
258
|
+
* 🛑 TIER 1 ADOPTION, kept for the FALLBACK path only.
|
|
259
|
+
*
|
|
260
|
+
* `searchPriceIndex` pays a `searchTxt` push-down query to turn a free-text
|
|
261
|
+
* term into category leaves — the resolution the local artifact does for
|
|
262
|
+
* free. When the dropdown is driving, no term is ever submitted and this
|
|
263
|
+
* stays idle; it matters when somebody lands on a shared `?q=` link.
|
|
264
|
+
*
|
|
265
|
+
* 🛑 Its `types` now come from `PRICE_LOOKUP_INDEX_TYPES`, not a hand-written
|
|
266
|
+
* `["model", "category"]`. That literal is why spares were unreachable: the
|
|
267
|
+
* index has had a `part` type the whole time and this call never asked for
|
|
268
|
+
* it, so no amount of typing a part name could surface one.
|
|
121
269
|
*/
|
|
122
270
|
const local = useSiteSearch(term, {
|
|
123
271
|
active: term.length >= 2,
|
|
124
272
|
indexEnabled: searchIndexEnabled,
|
|
125
|
-
types:
|
|
273
|
+
types: PRICE_LOOKUP_INDEX_TYPES,
|
|
126
274
|
label: identityLabel,
|
|
127
275
|
});
|
|
128
276
|
const localIds = React.useMemo(() => {
|
|
@@ -131,7 +279,13 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
|
|
|
131
279
|
const ids = new Set();
|
|
132
280
|
for (const group of local.result.groups) {
|
|
133
281
|
for (const hit of group.hits) {
|
|
134
|
-
|
|
282
|
+
/*
|
|
283
|
+
* 🛑 `priceLookupTargetFor`, not a bare `/categories/` regex. The old
|
|
284
|
+
* `categoryIdFromHref` matched that one shape and returned `null` for
|
|
285
|
+
* everything else, so a part hit was discarded here even on the rare
|
|
286
|
+
* occasion one arrived.
|
|
287
|
+
*/
|
|
288
|
+
const id = priceLookupTargetFor(hit.href);
|
|
135
289
|
if (id)
|
|
136
290
|
ids.add(id);
|
|
137
291
|
}
|
|
@@ -139,6 +293,14 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
|
|
|
139
293
|
return [...ids];
|
|
140
294
|
}, [local.result]);
|
|
141
295
|
const localReady = local.status === "ready";
|
|
296
|
+
/**
|
|
297
|
+
* The ids actually being looked up.
|
|
298
|
+
*
|
|
299
|
+
* A dropdown pick wins outright: it is an explicit choice of ONE thing, and
|
|
300
|
+
* mixing it with the term-resolved set would answer a question the user
|
|
301
|
+
* stopped asking the moment they picked.
|
|
302
|
+
*/
|
|
303
|
+
const lookupIds = React.useMemo(() => (picked ? [picked.id] : localReady ? localIds : []), [picked, localReady, localIds]);
|
|
142
304
|
/*
|
|
143
305
|
* The bands-only request. Enabled ONLY once local resolution has actually
|
|
144
306
|
* produced something — an empty `localIds` with the gate open would be a
|
|
@@ -146,19 +308,51 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
|
|
|
146
308
|
* but which is still a request worth not making.
|
|
147
309
|
*/
|
|
148
310
|
const bandsQuery = useQuery({
|
|
149
|
-
queryKey: ["tool", "price-index", "bands",
|
|
150
|
-
queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_BANDS(
|
|
151
|
-
enabled:
|
|
311
|
+
queryKey: ["tool", "price-index", "bands", lookupIds],
|
|
312
|
+
queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_BANDS(lookupIds)),
|
|
313
|
+
enabled: lookupIds.length > 0,
|
|
152
314
|
staleTime: Infinity,
|
|
153
315
|
refetchOnWindowFocus: false,
|
|
154
316
|
});
|
|
317
|
+
/**
|
|
318
|
+
* The MARKET half — a separate request, on purpose.
|
|
319
|
+
*
|
|
320
|
+
* 🛑 `enabled: canSeeMarket && …`. A guest never issues it, so the gate costs
|
|
321
|
+
* a signed-out visitor nothing and cannot 401-spam the console. The server
|
|
322
|
+
* enforces it too (`auth: true`); this is the client half of one decision,
|
|
323
|
+
* not the decision itself.
|
|
324
|
+
*
|
|
325
|
+
* 🛑 A separate request rather than two more fields on `bandsQuery`, because
|
|
326
|
+
* that route is PUBLIC and edge-cached: folding viewer-dependent content into
|
|
327
|
+
* it would let one signed-in lookup populate the entry every guest then
|
|
328
|
+
* reads. The full argument is in `price-index/market.ts`.
|
|
329
|
+
*
|
|
330
|
+
* 🛑 Its failure is deliberately NOT surfaced as the page's error. The sold
|
|
331
|
+
* band is the page; the ask is context. A 401 or a cold-start blip on the
|
|
332
|
+
* context must not replace a working result with "could not look that up".
|
|
333
|
+
*/
|
|
334
|
+
const marketQuery = useQuery({
|
|
335
|
+
queryKey: ["tool", "price-index", "market", lookupIds],
|
|
336
|
+
queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_MARKET(lookupIds)),
|
|
337
|
+
enabled: canSeeMarket && lookupIds.length > 0,
|
|
338
|
+
staleTime: Infinity,
|
|
339
|
+
refetchOnWindowFocus: false,
|
|
340
|
+
});
|
|
341
|
+
/** `categoryId` -> its market band, for the per-card join. */
|
|
342
|
+
const marketById = React.useMemo(() => {
|
|
343
|
+
const m = new Map();
|
|
344
|
+
for (const row of marketQuery.data?.results ?? [])
|
|
345
|
+
m.set(row.categoryId, row);
|
|
346
|
+
return m;
|
|
347
|
+
}, [marketQuery.data]);
|
|
155
348
|
/*
|
|
156
349
|
* The FALLBACK. Fires only when the local index is off, failed, or
|
|
157
350
|
* (rarely) resolved to nothing — never alongside the local path, so a
|
|
158
351
|
* working index never pays for both. Same cache posture as it always had.
|
|
159
352
|
*/
|
|
160
|
-
const fallbackEnabled = term.length >= 2 &&
|
|
161
|
-
(
|
|
353
|
+
const fallbackEnabled = !picked && term.length >= 2 &&
|
|
354
|
+
(local.status === "failed" || local.status === "disabled" ||
|
|
355
|
+
(localReady && localIds.length === 0));
|
|
162
356
|
const fallbackQuery = useQuery({
|
|
163
357
|
queryKey: ["tool", "price-index", term],
|
|
164
358
|
queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_SEARCH(term)),
|
|
@@ -175,11 +369,11 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
|
|
|
175
369
|
* how many ids were resolved, so there is nothing left uncounted the way
|
|
176
370
|
* the query-based path's search step could leave behind.
|
|
177
371
|
*/
|
|
178
|
-
const query =
|
|
372
|
+
const query = lookupIds.length > 0
|
|
179
373
|
? {
|
|
180
374
|
data: bandsQuery.data
|
|
181
375
|
? {
|
|
182
|
-
query: term,
|
|
376
|
+
query: picked ? picked.label : term,
|
|
183
377
|
results: bandsQuery.data.results,
|
|
184
378
|
truncated: false,
|
|
185
379
|
/*
|
|
@@ -215,5 +409,14 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
|
|
|
215
409
|
: null;
|
|
216
410
|
const threshold = data?.minConfidentSamples ?? 3;
|
|
217
411
|
const hasQuery = (data?.query ?? "").length > 0;
|
|
218
|
-
return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TrendingUp, { className: "w-5 h-5 text-primary-600", "aria-hidden": "true" }), _jsx(Heading, { level: 1, children: "What it sold for" })] }), _jsx(Text, { color: "muted", children: "
|
|
412
|
+
return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TrendingUp, { className: "w-5 h-5 text-primary-600", "aria-hidden": "true" }), _jsx(Heading, { level: 1, children: "What it sold for" })] }), _jsx(Text, { color: "muted", children: "Pick any bey, part or set to see what it actually sold for on LetItRip \u2014 the range, the median, the average and the most recent sale, broken down by condition \u2014 alongside what other Indian shops are asking for the same thing." })] }), dropdownUsable ? (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(PaginatedSelect, { value: picked?.id ?? null, onChange: (value, option) => setPicked(value && option ? { id: value, label: option.label } : null), loadOptions: loadOptions, placeholder: "Pick a bey, part or set\u2026", searchPlaceholder: "Type a name, code or part \u2014 e.g. Dragoon G, 1B, rubber flat", noResultsText: "Nothing in our catalogue matches that.", ariaLabel: "Search the catalogue for a price lookup" }), _jsx(Text, { size: "xs", color: "muted", children: "Models, parts and spares, eras and brands \u2014 straight from our own catalogue, so what you pick is a real thing we can price." })] })) : (
|
|
413
|
+
/*
|
|
414
|
+
* 🛑 The fallback, for a disabled or failed search artifact. Without
|
|
415
|
+
* it a kill-switch flip would leave an empty combobox and no way to
|
|
416
|
+
* search at all — the dropdown has no options without the artifact,
|
|
417
|
+
* but the server-side `searchTxt` path still works.
|
|
418
|
+
*/
|
|
419
|
+
_jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Row, { gap: "sm", align: "end", wrap: true, className: "min-w-0", children: [_jsx(Div, { className: "flex-1 min-w-[16rem]", children: _jsx(FieldInput, { name: "q", label: "Search", value: draft, onChange: setDraft, placeholder: "e.g. Dragoon G, Storm Pegasus, rubber flat\u2026" }) }), _jsxs(Button, { type: "submit", isLoading: loading, onClick: () => run(draft), children: [_jsx(Search, { className: "w-4 h-4", "aria-hidden": "true" }), "Look up"] })] })) })), error && (_jsx(Text, { size: "sm", color: "error", role: "alert", children: error })), !hasQuery && !loading && !error && (_jsxs(Stack, { gap: "xs", padding: "card", rounded: "lg", surface: "muted", className: "min-w-0", children: [_jsx(Text, { weight: "semibold", children: "Two numbers, and the gap matters" }), _jsxs(Text, { size: "sm", color: "muted", children: [_jsx(Span, { weight: "semibold", children: "Sold" }), " is a completed sale on LetItRip \u2014 not an asking price and not a guess. We publish a figure only once ", threshold, " or more sales support it, and the index is recomputed once a day, so it is deliberately a day behind."] }), _jsxs(Text, { size: "sm", color: "muted", children: [_jsx(Span, { weight: "semibold", children: "Asked" }), " is what other Indian shops list the same thing for, measured across fifteen catalogues.", canSeeMarket
|
|
420
|
+
? " Shown beside our own sales — never averaged with them, because an ask is not a sale."
|
|
421
|
+
: " Sign in to see it beside our own sales."] })] })), hasQuery && data && data.results.length === 0 && !loading && (_jsxs(Text, { color: "muted", children: ["Nothing matched \u201C", data.query, "\u201D. Try a shorter term, or the model name on its own."] })), data && data.results.length > 0 && (_jsxs(Stack, { gap: "md", className: "min-w-0", children: [data.results.map((row) => (_jsx(ResultCard, { row: row, threshold: threshold, market: marketById.get(row.categoryId), canSeeMarket: canSeeMarket }, row.categoryId))), data.truncated && (_jsxs(Text, { size: "sm", color: "muted", children: ["Showing the closest ", data.results.length, " matches. Narrow the search to see others."] }))] }))] }) }) }));
|
|
219
422
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The index types the price tool searches.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 Exactly the types that can RESOLVE TO A PRICEABLE NODE, which is a
|
|
5
|
+
* narrower set than "everything in the index" and deliberately so:
|
|
6
|
+
*
|
|
7
|
+
* `model` -> its model leaf (href is `/categories/{categoryId}`)
|
|
8
|
+
* `category` -> itself, incl. the era and part-group leaves the crawl
|
|
9
|
+
* actually measured
|
|
10
|
+
* `part` -> its part-group leaf via `PART_SLOT_CATEGORY` — this is the
|
|
11
|
+
* one that makes spares work, and those leaves
|
|
12
|
+
* (`category-metal-energy-rings`, `category-burst-layers`, …)
|
|
13
|
+
* are precisely where `MARKET_BANDS` has component pricing
|
|
14
|
+
* `brand` -> itself; brands are `categories` rows with
|
|
15
|
+
* `categoryType: "brand"`
|
|
16
|
+
*
|
|
17
|
+
* 🛑 What is NOT here, and why — each of these was asked for and cannot be
|
|
18
|
+
* delivered from the client index as it stands:
|
|
19
|
+
*
|
|
20
|
+
* `group` `groupedListings` is not a category, so no band can
|
|
21
|
+
* attach to it at all.
|
|
22
|
+
* the 10 listing A product record's href is `/products/{slug}` and the
|
|
23
|
+
* types record carries no `categoryId`, so resolving one needs a
|
|
24
|
+
* server read per hit. A price band is a statement about a
|
|
25
|
+
* leaf, not about one seller's listing.
|
|
26
|
+
* `bundle`, NOT IN THE INDEX. No `IndexSource` emits them and the
|
|
27
|
+
* `franchise` `category` source rejects any `categoryType` other than
|
|
28
|
+
* `sublisting`, so adding them is an index-build change,
|
|
29
|
+
* not a filter here.
|
|
30
|
+
*
|
|
31
|
+
* 🛑 `brand` is included as asked, and the honest caveat is that a brand row is
|
|
32
|
+
* a tier-0 node OUTSIDE the forest with no `parentIds`, so it inherits no band
|
|
33
|
+
* and will usually resolve to "no market evidence". That renders as an honest
|
|
34
|
+
* empty rather than a wrong number, but it is a thin result and the reason is
|
|
35
|
+
* the data, not the lookup.
|
|
36
|
+
*/
|
|
37
|
+
export declare const PRICE_LOOKUP_INDEX_TYPES: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* The category id whose band answers a search hit, or `null` when the hit
|
|
40
|
+
* cannot be priced.
|
|
41
|
+
*
|
|
42
|
+
* 🛑 `null` is a real answer and must not be collapsed into a guess. The
|
|
43
|
+
* alternative — falling back to some ancestor, or to the hit's own slug as if
|
|
44
|
+
* it were a category id — would attach a band to a node that does not have one
|
|
45
|
+
* and render a confident number for the wrong thing. A dropped option is
|
|
46
|
+
* visible; a misattributed price is not.
|
|
47
|
+
*/
|
|
48
|
+
export declare function priceLookupTargetFor(href: string): string | null;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: The realised-price tool looks prices up per CATEGORY leaf, but the thing
|
|
3
|
+
* a person searches for is a model, a part, a brand or an era — four
|
|
4
|
+
* different index types with four different href shapes. Turning a search
|
|
5
|
+
* hit into "the leaf whose band answers this" was previously a single
|
|
6
|
+
* regex over `/categories/...` inside the view, which silently dropped
|
|
7
|
+
* every part hit; the tool asked the index for `["model", "category"]`
|
|
8
|
+
* only, so spares were unreachable even though `part` is indexed.
|
|
9
|
+
* WHAT: One pure function from a search hit to the category id to look up, plus
|
|
10
|
+
* the type list the tool should ask the matcher for.
|
|
11
|
+
*
|
|
12
|
+
* EXPORTS:
|
|
13
|
+
* PRICE_LOOKUP_INDEX_TYPES — which index types the tool searches
|
|
14
|
+
* priceLookupTargetFor — a hit's href -> the category id, or null
|
|
15
|
+
*
|
|
16
|
+
* 🛑 Pure. No React, no Firestore, no fetch — it runs inside a `loadOptions`
|
|
17
|
+
* callback on every keystroke.
|
|
18
|
+
*
|
|
19
|
+
* @tag domain:price-index
|
|
20
|
+
* @tag layer:feature
|
|
21
|
+
* @tag pattern:pure-function
|
|
22
|
+
* @tag access:isomorphic
|
|
23
|
+
* @tag consumers:PriceIndexToolView
|
|
24
|
+
* @tag sideEffects:none
|
|
25
|
+
*/
|
|
26
|
+
import { PART_SLOT_CATEGORY } from "../parts/schemas/firestore.js";
|
|
27
|
+
/**
|
|
28
|
+
* The index types the price tool searches.
|
|
29
|
+
*
|
|
30
|
+
* 🛑 Exactly the types that can RESOLVE TO A PRICEABLE NODE, which is a
|
|
31
|
+
* narrower set than "everything in the index" and deliberately so:
|
|
32
|
+
*
|
|
33
|
+
* `model` -> its model leaf (href is `/categories/{categoryId}`)
|
|
34
|
+
* `category` -> itself, incl. the era and part-group leaves the crawl
|
|
35
|
+
* actually measured
|
|
36
|
+
* `part` -> its part-group leaf via `PART_SLOT_CATEGORY` — this is the
|
|
37
|
+
* one that makes spares work, and those leaves
|
|
38
|
+
* (`category-metal-energy-rings`, `category-burst-layers`, …)
|
|
39
|
+
* are precisely where `MARKET_BANDS` has component pricing
|
|
40
|
+
* `brand` -> itself; brands are `categories` rows with
|
|
41
|
+
* `categoryType: "brand"`
|
|
42
|
+
*
|
|
43
|
+
* 🛑 What is NOT here, and why — each of these was asked for and cannot be
|
|
44
|
+
* delivered from the client index as it stands:
|
|
45
|
+
*
|
|
46
|
+
* `group` `groupedListings` is not a category, so no band can
|
|
47
|
+
* attach to it at all.
|
|
48
|
+
* the 10 listing A product record's href is `/products/{slug}` and the
|
|
49
|
+
* types record carries no `categoryId`, so resolving one needs a
|
|
50
|
+
* server read per hit. A price band is a statement about a
|
|
51
|
+
* leaf, not about one seller's listing.
|
|
52
|
+
* `bundle`, NOT IN THE INDEX. No `IndexSource` emits them and the
|
|
53
|
+
* `franchise` `category` source rejects any `categoryType` other than
|
|
54
|
+
* `sublisting`, so adding them is an index-build change,
|
|
55
|
+
* not a filter here.
|
|
56
|
+
*
|
|
57
|
+
* 🛑 `brand` is included as asked, and the honest caveat is that a brand row is
|
|
58
|
+
* a tier-0 node OUTSIDE the forest with no `parentIds`, so it inherits no band
|
|
59
|
+
* and will usually resolve to "no market evidence". That renders as an honest
|
|
60
|
+
* empty rather than a wrong number, but it is a thin result and the reason is
|
|
61
|
+
* the data, not the lookup.
|
|
62
|
+
*/
|
|
63
|
+
export const PRICE_LOOKUP_INDEX_TYPES = [
|
|
64
|
+
"model",
|
|
65
|
+
"category",
|
|
66
|
+
"part",
|
|
67
|
+
"brand",
|
|
68
|
+
];
|
|
69
|
+
/** `/categories/{id}` and `/brands/{id}` — category documents are pure slugs. */
|
|
70
|
+
const DIRECT_CATEGORY_HREF = /^\/(?:categories|brands)\/([^/?#]+)/;
|
|
71
|
+
/**
|
|
72
|
+
* `/parts/{classification}/{code}`.
|
|
73
|
+
*
|
|
74
|
+
* 🛑 TWO segments, and the first is the slot. `ROUTES.PUBLIC.PART_DETAIL`'s own
|
|
75
|
+
* comment explains why a code alone cannot be a URL: "`105` is a Metal Fight
|
|
76
|
+
* spin-track height, a bare number in Burst is a disc, and `1-60` is a Beyblade
|
|
77
|
+
* X ratchet." The slot is what places the part in the taxonomy.
|
|
78
|
+
*/
|
|
79
|
+
const PART_HREF = /^\/parts\/([^/?#]+)\/([^/?#]+)/;
|
|
80
|
+
/**
|
|
81
|
+
* The category id whose band answers a search hit, or `null` when the hit
|
|
82
|
+
* cannot be priced.
|
|
83
|
+
*
|
|
84
|
+
* 🛑 `null` is a real answer and must not be collapsed into a guess. The
|
|
85
|
+
* alternative — falling back to some ancestor, or to the hit's own slug as if
|
|
86
|
+
* it were a category id — would attach a band to a node that does not have one
|
|
87
|
+
* and render a confident number for the wrong thing. A dropped option is
|
|
88
|
+
* visible; a misattributed price is not.
|
|
89
|
+
*/
|
|
90
|
+
export function priceLookupTargetFor(href) {
|
|
91
|
+
const direct = DIRECT_CATEGORY_HREF.exec(href);
|
|
92
|
+
if (direct)
|
|
93
|
+
return direct[1] ?? null;
|
|
94
|
+
const part = PART_HREF.exec(href);
|
|
95
|
+
if (part) {
|
|
96
|
+
const slot = part[1];
|
|
97
|
+
/*
|
|
98
|
+
* `PART_SLOT_CATEGORY` is a `Record<PartSlot, string>`, so a 21st slot is a
|
|
99
|
+
* compile error there rather than a silent miss here — but the href came
|
|
100
|
+
* off an artifact built from live documents, so a slot this build has never
|
|
101
|
+
* heard of is still possible and resolves to `null` rather than
|
|
102
|
+
* `undefined` leaking into a URL.
|
|
103
|
+
*/
|
|
104
|
+
return PART_SLOT_CATEGORY[slot] ?? null;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -13,6 +13,7 @@ import { NextResponse } from "next/server.js";
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { getProviders } from "../../../../contracts/index.js";
|
|
15
15
|
import { createRouteHandler } from "../../../../next/api/routeHandler.js";
|
|
16
|
+
import { ALL_LISTING_TYPES } from "../../../../_internal/shared/listing-types/feature-flags.js";
|
|
16
17
|
import { mediaFieldSchema } from "../../../media/types/index.js";
|
|
17
18
|
import { storeRepository } from "../../../stores/repository/store.repository.js";
|
|
18
19
|
// SB-UNI-V — bundlesRepository deleted; bundle stock-sync moves to the
|
|
@@ -55,8 +56,14 @@ const productUpdateSchema = z
|
|
|
55
56
|
isPromoted: z.boolean().optional(),
|
|
56
57
|
allowShipBeforeEmiComplete: z.boolean().optional(),
|
|
57
58
|
printMeta: printMetaSchema.optional(),
|
|
59
|
+
/*
|
|
60
|
+
* 🛑 DERIVED from `ALL_LISTING_TYPES`, never restated. This literal was
|
|
61
|
+
* wrong in both directions: `spares` missing (so the type could not be
|
|
62
|
+
* PATCHed onto a listing at all) and `bundle` still present (so a caller
|
|
63
|
+
* could write a value no registry has a branch for). Root Cause #61.
|
|
64
|
+
*/
|
|
58
65
|
listingType: z
|
|
59
|
-
.enum(
|
|
66
|
+
.enum(ALL_LISTING_TYPES)
|
|
60
67
|
.optional(),
|
|
61
68
|
media: z.array(mediaFieldSchema).optional(),
|
|
62
69
|
})
|
|
@@ -16,6 +16,7 @@ import { NextResponse } from "next/server.js";
|
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { getProviders } from "../../../contracts/index.js";
|
|
18
18
|
import { createRouteHandler } from "../../../next/api/routeHandler.js";
|
|
19
|
+
import { ALL_LISTING_TYPES } from "../../../_internal/shared/listing-types/feature-flags.js";
|
|
19
20
|
import { mediaFieldSchema } from "../../media/types/index.js";
|
|
20
21
|
import { printMetaSchema } from "../schemas/index.js";
|
|
21
22
|
import { sanitizeProductsForPublic } from "../utils/sanitize.js";
|
|
@@ -50,11 +51,22 @@ const productMutateSchema = z
|
|
|
50
51
|
sellerName: z.string().optional(),
|
|
51
52
|
sellerEmail: z.string().email().optional(),
|
|
52
53
|
slug: z.string().optional(),
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
/*
|
|
55
|
+
* 🛑 DERIVED from `ALL_LISTING_TYPES`, never restated.
|
|
56
|
+
*
|
|
57
|
+
* The literal that used to sit here is the cautionary tale for why. Its
|
|
58
|
+
* comment correctly explained that `bundle` is absent because SB-UNI-D
|
|
59
|
+
* moved bundles onto `categories` — so the dead value had been noticed and
|
|
60
|
+
* removed — and the list was STILL wrong, because `spares` was added to the
|
|
61
|
+
* union on 2026-10-19 and never reached here. A fix for one half of the
|
|
62
|
+
* drift, documented in a comment, with the other half drifting one field
|
|
63
|
+
* over underneath it.
|
|
64
|
+
*
|
|
65
|
+
* Deriving removes both failure modes at once: a dead value cannot survive
|
|
66
|
+
* and a new member cannot be forgotten. Root Cause #61.
|
|
67
|
+
*/
|
|
56
68
|
listingType: z
|
|
57
|
-
.enum(
|
|
69
|
+
.enum(ALL_LISTING_TYPES)
|
|
58
70
|
.optional(),
|
|
59
71
|
media: z.array(mediaFieldSchema).optional(),
|
|
60
72
|
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ListingType } from "../types/index";
|
|
3
|
+
export interface AvailabilityScopeHintProps {
|
|
4
|
+
/**
|
|
5
|
+
* Every published row in this tab's scope, all availability scopes.
|
|
6
|
+
* `undefined` means the count is unknown — the hint renders nothing rather
|
|
7
|
+
* than guessing (Root Cause #72: unknown must never read as a number).
|
|
8
|
+
*/
|
|
9
|
+
total?: number;
|
|
10
|
+
/** Rows the AVAILABLE scope renders. Same `undefined` rule. */
|
|
11
|
+
available?: number;
|
|
12
|
+
/**
|
|
13
|
+
* The listing types this tab spans. The hidden-rows noun ("sold", "ended",
|
|
14
|
+
* "sold & ended") is DERIVED from their plugins via `availabilityTabsFor`,
|
|
15
|
+
* which is the same function that labels the scope tabs — so the hint and
|
|
16
|
+
* the control it points at can never disagree about the word. A hand-written
|
|
17
|
+
* string here would be a tenth place the `ListingType` union is enumerated
|
|
18
|
+
* by hand (Root Cause #61).
|
|
19
|
+
*/
|
|
20
|
+
types: readonly ListingType[];
|
|
21
|
+
}
|
|
22
|
+
export declare function AvailabilityScopeHint({ total, available, types, }: AvailabilityScopeHintProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Row, Text } from "../../../ui/index.js";
|
|
4
|
+
import { useUrlTable } from "../../../react/hooks/useUrlTable.js";
|
|
5
|
+
import { AVAILABILITY_VALUES } from "../../../constants/field-names.js";
|
|
6
|
+
import { TABLE_KEYS } from "../../../constants/table-keys.js";
|
|
7
|
+
import { availabilityTabsFor } from "../../../_internal/shared/listing-types/_registry.js";
|
|
8
|
+
export function AvailabilityScopeHint({ total, available, types, }) {
|
|
9
|
+
const table = useUrlTable();
|
|
10
|
+
const scope = table.get(TABLE_KEYS.AVAILABILITY) ||
|
|
11
|
+
AVAILABILITY_VALUES.AVAILABLE;
|
|
12
|
+
// Only the default scope withholds anything. In Ended or All the visitor is
|
|
13
|
+
// already looking at the rows this hint would point them to.
|
|
14
|
+
if (scope !== AVAILABILITY_VALUES.AVAILABLE)
|
|
15
|
+
return null;
|
|
16
|
+
if (total === undefined || available === undefined)
|
|
17
|
+
return null;
|
|
18
|
+
const hidden = total - available;
|
|
19
|
+
if (hidden <= 0)
|
|
20
|
+
return null;
|
|
21
|
+
// availabilityTabsFor()[1] is the unavailable scope — "Sold", "Ended" or
|
|
22
|
+
// "Sold & Ended", already derived per type.
|
|
23
|
+
const noun = (availabilityTabsFor(types)[1]?.label ?? "Sold").toLowerCase();
|
|
24
|
+
return (_jsxs(Row, { align: "center", gap: "sm", padding: "sm", surface: "muted", rounded: "lg", border: "subtle", wrap: true, className: "mb-4", children: [_jsx(Text, { size: "sm", color: "muted", children: hidden === 1
|
|
25
|
+
? `1 listing here is ${noun} and hidden by the Available filter.`
|
|
26
|
+
: `${hidden} listings here are ${noun} and hidden by the Available filter.` }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => table.set(TABLE_KEYS.AVAILABILITY, AVAILABILITY_VALUES.ALL), children: "Show all" })] }));
|
|
27
|
+
}
|