@mohasinac/appkit 4.53.1 → 4.53.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/theme/ThemeProvider.js +15 -1
- 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/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 +128 -10
- package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
- package/dist/_internal/server/features/stores/tabs.js +5 -3
- package/dist/_internal/shared/search/match.js +28 -2
- package/dist/constants/api-endpoints.d.ts +7 -0
- package/dist/constants/api-endpoints.js +7 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
- package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
- package/dist/features/categories/components/BrandDetailPageView.js +14 -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 +17 -7
- package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
- package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
- package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
- 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/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/ProductDetailPageView.js +7 -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/seller/components/SellerProductsView.js +3 -2
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -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 +12 -0
- package/dist/seed/products-beyblade-x-seed-data.js +36 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +133 -27
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* the tab bar renders, so a tenth listing type is counted automatically.
|
|
12
12
|
*
|
|
13
13
|
* EXPORTS:
|
|
14
|
-
* ListingTabScope
|
|
15
|
-
* ListingTabCounts
|
|
16
|
-
*
|
|
14
|
+
* ListingTabScope — the surface's scope (category / brand / store)
|
|
15
|
+
* ListingTabCounts — tabSlug -> count, `undefined` meaning "unknown"
|
|
16
|
+
* ListingTabCountResult — the AVAILABLE and TOTAL maps, see below
|
|
17
|
+
* listingTabCounts — resolve every tab's counts in one parallel batch
|
|
17
18
|
*
|
|
18
19
|
* @tag domain:products,categories,stores
|
|
19
20
|
* @tag layer:server
|
|
@@ -25,6 +26,11 @@
|
|
|
25
26
|
import { categoriesRepository, productRepository } from "../../../../repositories/index.js";
|
|
26
27
|
import { serverLogger } from "../../../../monitoring/index.js";
|
|
27
28
|
import { normalizeError } from "../../../../errors/normalize.js";
|
|
29
|
+
import { sieveAnd, sieveFilter, SIEVE_OP, sortBy } from "../../../../utils/sieve-builder.js";
|
|
30
|
+
import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
|
|
31
|
+
import { isListingRowAvailable, unavailableClausesFor, } from "../../../shared/listing-types/_registry.js";
|
|
32
|
+
import { normalizeListingType } from "../../../../features/products/utils/listing-type.js";
|
|
33
|
+
import { clauseToSieve, PUBLIC_PRODUCT_MAX_PAGE_SIZE } from "./list-public.js";
|
|
28
34
|
const DEFAULT_STATUS = "published";
|
|
29
35
|
/** Bundles a category owns, resolved by the same rule the bundles tab renders. */
|
|
30
36
|
async function countBundles(scope) {
|
|
@@ -65,6 +71,101 @@ function bundleMatchesScope(bundle, scope) {
|
|
|
65
71
|
}
|
|
66
72
|
return true;
|
|
67
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* How many rows in scope are NOT available, per listing type.
|
|
76
|
+
*
|
|
77
|
+
* ── 🛑 WHY THIS IS NOT AN AGGREGATE COUNT ─────────────────────────────────
|
|
78
|
+
*
|
|
79
|
+
* Two independent reasons, either of which alone is disqualifying.
|
|
80
|
+
*
|
|
81
|
+
* 1. Unavailability is an OR across DIFFERENT fields (`availableQuantity == 0`
|
|
82
|
+
* OR `isSold == true` OR `prizeRevealStatus == closed` …). Firestore cannot
|
|
83
|
+
* express that in one query, and counting each clause separately and adding
|
|
84
|
+
* them double-counts every row matching two of them.
|
|
85
|
+
*
|
|
86
|
+
* 2. A clause selects a SUPERSET, deliberately. `madeToOrder` is pointedly NOT
|
|
87
|
+
* a clause (see the note in `availability.ts`), so a print-on-demand sticker
|
|
88
|
+
* sitting at `availableQuantity: 0` matches `availableQuantity == 0` and is
|
|
89
|
+
* nevertheless AVAILABLE. Only `isListingRowAvailable` knows that, and it is
|
|
90
|
+
* a per-row predicate. Measured on the category that prompted this work,
|
|
91
|
+
* a clause-count would have badged "Art & Stickers 2" over three available
|
|
92
|
+
* rows — trading one wrong number for another.
|
|
93
|
+
*
|
|
94
|
+
* So this is Path C of `listPublicProducts` reused in miniature: one bounded
|
|
95
|
+
* query per DISTINCT clause, in parallel, merged and deduped by id, then every
|
|
96
|
+
* surviving row confirmed with the real predicate. Nine listing types collapse
|
|
97
|
+
* to five clauses, so the whole tab bar costs ~5 × 50 documents in one parallel
|
|
98
|
+
* round — the same fan-out and the same budget that scope already spends.
|
|
99
|
+
*
|
|
100
|
+
* 🛑 The queries are NOT narrowed to the tab's listing types. Firestore permits
|
|
101
|
+
* one array operator per query and the category scope already spends it on
|
|
102
|
+
* `categorySlugs array-contains-any`, so adding `listingType in (…)` risks an
|
|
103
|
+
* INVALID_ARGUMENT for no gain: unavailable rows are sparse, and tallying each
|
|
104
|
+
* row by its own `listingType` afterwards answers every tab from one pass.
|
|
105
|
+
*/
|
|
106
|
+
async function unavailableByType(scope, types, now) {
|
|
107
|
+
const byType = new Map();
|
|
108
|
+
const clauses = unavailableClausesFor(types);
|
|
109
|
+
if (clauses.length === 0)
|
|
110
|
+
return { byType, truncated: false };
|
|
111
|
+
const status = scope.status ?? DEFAULT_STATUS;
|
|
112
|
+
const results = await Promise.all(clauses.map(async (clause) => {
|
|
113
|
+
// An inequality must be ordered by its own field — Firestore appends it
|
|
114
|
+
// to the `orderBy` implicitly, so any other sort demands a composite
|
|
115
|
+
// index nobody declared (Root Cause #59).
|
|
116
|
+
const sorts = clause.op === "lt" && clause.sortField
|
|
117
|
+
? sortBy(clause.sortField, "DESC")
|
|
118
|
+
: sortBy(PRODUCT_FIELDS.CREATED_AT, "DESC");
|
|
119
|
+
try {
|
|
120
|
+
return await productRepository.list({
|
|
121
|
+
filters: sieveAnd(sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, status), clauseToSieve(clause, now)),
|
|
122
|
+
sorts,
|
|
123
|
+
page: 1,
|
|
124
|
+
pageSize: PUBLIC_PRODUCT_MAX_PAGE_SIZE,
|
|
125
|
+
}, {
|
|
126
|
+
storeId: scope.storeId,
|
|
127
|
+
categoriesIn: scope.categoriesIn,
|
|
128
|
+
brandName: scope.brandName,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const e = normalizeError(error);
|
|
133
|
+
// ERROR, not warn: a clause that cannot run is almost always a missing
|
|
134
|
+
// composite index — a permanent, silent skew of every badge on the
|
|
135
|
+
// page rather than a transient blip.
|
|
136
|
+
serverLogger.error(`listingTabCounts: unavailable clause failed (${clause.field} ${clause.op}); badges fall back to totals`, { field: clause.field, op: clause.op, error: e.message });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}));
|
|
140
|
+
const merged = new Map();
|
|
141
|
+
let truncated = false;
|
|
142
|
+
for (const result of results) {
|
|
143
|
+
if (!result) {
|
|
144
|
+
truncated = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const rows = result.items;
|
|
148
|
+
if (rows.length >= PUBLIC_PRODUCT_MAX_PAGE_SIZE)
|
|
149
|
+
truncated = true;
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const id = typeof row.id === "string" ? row.id : JSON.stringify(row.id);
|
|
152
|
+
if (!merged.has(id))
|
|
153
|
+
merged.set(id, row);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const row of merged.values()) {
|
|
157
|
+
// The clause matched a superset; this is the real answer.
|
|
158
|
+
if (isListingRowAvailable(row, now))
|
|
159
|
+
continue;
|
|
160
|
+
const type = normalizeListingType(row);
|
|
161
|
+
byType.set(type, (byType.get(type) ?? 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
return { byType, truncated };
|
|
164
|
+
}
|
|
165
|
+
/** The listing types one tab spans — `"art|stickers"` is a pipe OR-group. */
|
|
166
|
+
function typesOf(tab) {
|
|
167
|
+
return String(tab.listingType).split("|").filter(Boolean);
|
|
168
|
+
}
|
|
68
169
|
/**
|
|
69
170
|
* Count every tab in `tabs` within `scope`, in one parallel batch.
|
|
70
171
|
*
|
|
@@ -75,10 +176,12 @@ function bundleMatchesScope(bundle, scope) {
|
|
|
75
176
|
* `stores` is not counted here: only the category page has that tab and it
|
|
76
177
|
* already resolves its store list eagerly to render the tab body.
|
|
77
178
|
*/
|
|
78
|
-
export async function listingTabCounts(tabs, scope) {
|
|
179
|
+
export async function listingTabCounts(tabs, scope, opts) {
|
|
180
|
+
const now = opts?.now ?? new Date();
|
|
79
181
|
const listingTabs = tabs.filter((tab) => Boolean(tab.listingType));
|
|
80
182
|
const wantsBundles = tabs.some((tab) => tab.collection === "bundles");
|
|
81
|
-
const
|
|
183
|
+
const allTypes = [...new Set(listingTabs.flatMap(typesOf))];
|
|
184
|
+
const [typeCounts, bundleCount, unavailable] = await Promise.all([
|
|
82
185
|
productRepository.countByListingTypes(listingTabs.map((tab) => String(tab.listingType)), {
|
|
83
186
|
status: scope.status ?? DEFAULT_STATUS,
|
|
84
187
|
storeId: scope.storeId,
|
|
@@ -86,12 +189,27 @@ export async function listingTabCounts(tabs, scope) {
|
|
|
86
189
|
brandName: scope.brandName,
|
|
87
190
|
}),
|
|
88
191
|
wantsBundles ? countBundles(scope) : Promise.resolve(undefined),
|
|
192
|
+
unavailableByType(scope, allTypes, now),
|
|
89
193
|
]);
|
|
90
|
-
const
|
|
194
|
+
const total = {};
|
|
195
|
+
const available = {};
|
|
91
196
|
for (const tab of listingTabs) {
|
|
92
|
-
|
|
197
|
+
const totalForTab = typeCounts[String(tab.listingType)];
|
|
198
|
+
total[tab.id] = totalForTab;
|
|
199
|
+
if (totalForTab === undefined || unavailable.truncated) {
|
|
200
|
+
// Unknown beats a number that is quietly too high. Falling back to the
|
|
201
|
+
// total reproduces the pre-2026-10-20 badge rather than inventing one.
|
|
202
|
+
available[tab.id] = totalForTab;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const gone = typesOf(tab).reduce((sum, type) => sum + (unavailable.byType.get(type) ?? 0), 0);
|
|
206
|
+
available[tab.id] = Math.max(0, totalForTab - gone);
|
|
207
|
+
}
|
|
208
|
+
if (wantsBundles) {
|
|
209
|
+
// A bundle has no availability scope of its own — the bundles tab renders
|
|
210
|
+
// active bundle ROWS, not products, so the two numbers are one number.
|
|
211
|
+
total.bundles = bundleCount;
|
|
212
|
+
available.bundles = bundleCount;
|
|
93
213
|
}
|
|
94
|
-
|
|
95
|
-
counts.bundles = bundleCount;
|
|
96
|
-
return counts;
|
|
214
|
+
return { available, total };
|
|
97
215
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isListingTypeEnabled } from "../../../shared/listing-types/feature-flags";
|
|
2
|
-
import { type
|
|
2
|
+
import { type ListingTabCountResult } from "../products/listing-tab-counts";
|
|
3
3
|
type SiteSettingsSnapshot = Parameters<typeof isListingTypeEnabled>[1];
|
|
4
4
|
export interface StoreTabLink {
|
|
5
5
|
value: string;
|
|
@@ -11,7 +11,12 @@ export interface ResolvedStoreTabs {
|
|
|
11
11
|
dropdownTabs: StoreTabLink[];
|
|
12
12
|
/** Coupons (if any), Reviews (if any), About — always non-empty. */
|
|
13
13
|
tabs: StoreTabLink[];
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Both numbers, because they answer different questions: `available` is what
|
|
16
|
+
* each tab badges, `total` is what decides whether the tab exists at all.
|
|
17
|
+
* See `ListingTabCountResult`.
|
|
18
|
+
*/
|
|
19
|
+
listingCounts: ListingTabCountResult;
|
|
15
20
|
couponsCount: number;
|
|
16
21
|
reviewsCount: number;
|
|
17
22
|
}
|
|
@@ -73,7 +73,7 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
|
|
|
73
73
|
.then((r) => r.total)
|
|
74
74
|
.catch(() => 0),
|
|
75
75
|
])
|
|
76
|
-
: [{}, 0, 0];
|
|
76
|
+
: [{ available: {}, total: {} }, 0, 0];
|
|
77
77
|
const visibleStoreTabs = STORE_PAGE_TABS.filter((tab) => {
|
|
78
78
|
if (tab.id === "bundles") {
|
|
79
79
|
if (!isCategoryTypeEnabled("bundle", settings))
|
|
@@ -92,12 +92,14 @@ export async function resolveStoreTabs(storeSlug, storeId, settings) {
|
|
|
92
92
|
// that leads to an empty page. `undefined` means the count query FAILED,
|
|
93
93
|
// not zero, so the tab stays visible rather than hiding real stock
|
|
94
94
|
// behind a swallowed error (Root Cause #59).
|
|
95
|
-
|
|
95
|
+
// TOTAL, not the badge: a tab whose rows have all ended still has an
|
|
96
|
+
// archive behind the Ended/All scopes, and hiding it would strand that.
|
|
97
|
+
const count = listingCounts.total[tab.id];
|
|
96
98
|
return count === undefined || count > 0;
|
|
97
99
|
});
|
|
98
100
|
const dropdownTabs = visibleStoreTabs.map((tab) => ({
|
|
99
101
|
value: tab.id,
|
|
100
|
-
label: tabLabel(tab.label, listingCounts[tab.id]),
|
|
102
|
+
label: tabLabel(tab.label, listingCounts.available[tab.id]),
|
|
101
103
|
href: STORE_LISTING_HREF[tab.id](storeSlug),
|
|
102
104
|
}));
|
|
103
105
|
const tabs = [
|
|
@@ -119,6 +119,30 @@ export function prepareIndex(artifact) {
|
|
|
119
119
|
};
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The shortest term allowed to match MID-WORD.
|
|
124
|
+
*
|
|
125
|
+
* 🛑 This exists because of a reported bug, and the mechanism is worth stating:
|
|
126
|
+
* the substring tier is `flat.includes(term)`, and for a one-character term
|
|
127
|
+
* that is satisfied by almost every record in the artifact. A category's
|
|
128
|
+
* `detail` is its " › "-joined path, so `"g"` matches
|
|
129
|
+
* `"Spinning Tops › Beyblade Original › Dragoon Line"` on the `g` in
|
|
130
|
+
* "Spinning". AND semantics then cannot narrow anything: `"dragoon g"` scored
|
|
131
|
+
* identically to `"dragoon"` and returned every Dragoon in the catalogue —
|
|
132
|
+
* which is the "even if I type dragoon g the results show everything"
|
|
133
|
+
* complaint, and it was never the AND logic's fault.
|
|
134
|
+
*
|
|
135
|
+
* 🛑 It does NOT reintroduce a minimum query length. This module's header is
|
|
136
|
+
* explicit that typing `"d"` and seeing something is what a local index is FOR,
|
|
137
|
+
* and it still does: the EXACT and PREFIX tiers are untouched, so a
|
|
138
|
+
* one-character term still matches any word starting with it. Only the mid-word
|
|
139
|
+
* tier is gated, because one character appearing somewhere inside a string is
|
|
140
|
+
* not evidence of intent.
|
|
141
|
+
*
|
|
142
|
+
* Two rather than three: `"cx"`, `"bu"`, `"gt"` and `"1b"` are real product
|
|
143
|
+
* vocabulary a collector types, and they discriminate.
|
|
144
|
+
*/
|
|
145
|
+
const MIN_SUBSTRING_TERM = 2;
|
|
122
146
|
/** Best match quality for one term against one field. 0 means no match. */
|
|
123
147
|
function fieldScore(term, words, flat) {
|
|
124
148
|
if (!flat)
|
|
@@ -129,8 +153,10 @@ function fieldScore(term, words, flat) {
|
|
|
129
153
|
for (const w of words)
|
|
130
154
|
if (w.startsWith(term))
|
|
131
155
|
return S_PREFIX;
|
|
132
|
-
// Mid-word. The capability the token index cannot have
|
|
133
|
-
|
|
156
|
+
// Mid-word. The capability the token index cannot have — but see
|
|
157
|
+
// MIN_SUBSTRING_TERM: for a single character it is not a capability, it is a
|
|
158
|
+
// match against everything.
|
|
159
|
+
if (term.length >= MIN_SUBSTRING_TERM && flat.includes(term))
|
|
134
160
|
return S_SUBSTRING;
|
|
135
161
|
return 0;
|
|
136
162
|
}
|
|
@@ -301,6 +301,13 @@ export declare const TOOL_ENDPOINTS: {
|
|
|
301
301
|
* is the multi-get `searchPriceIndex` otherwise pays for every lookup.
|
|
302
302
|
*/
|
|
303
303
|
readonly PRICE_INDEX_BANDS: (ids: readonly string[]) => string;
|
|
304
|
+
/**
|
|
305
|
+
* The competitor-crawl market band for the same leaves — SIGNED-IN ONLY, and
|
|
306
|
+
* therefore a separate request from `PRICE_INDEX_BANDS` rather than more
|
|
307
|
+
* fields on it. That route is public and edge-cached; this one must not be,
|
|
308
|
+
* or one signed-in lookup populates an entry every guest reads.
|
|
309
|
+
*/
|
|
310
|
+
readonly PRICE_INDEX_MARKET: (ids: readonly string[]) => string;
|
|
304
311
|
};
|
|
305
312
|
/**
|
|
306
313
|
* The sticker-sheet kiss-cut tracer. Admin-only, every route behind
|
|
@@ -348,6 +348,13 @@ export const TOOL_ENDPOINTS = {
|
|
|
348
348
|
* is the multi-get `searchPriceIndex` otherwise pays for every lookup.
|
|
349
349
|
*/
|
|
350
350
|
PRICE_INDEX_BANDS: (ids) => `/api/tools/price-index/bands?ids=${ids.map(encodeURIComponent).join(",")}`,
|
|
351
|
+
/**
|
|
352
|
+
* The competitor-crawl market band for the same leaves — SIGNED-IN ONLY, and
|
|
353
|
+
* therefore a separate request from `PRICE_INDEX_BANDS` rather than more
|
|
354
|
+
* fields on it. That route is public and edge-cached; this one must not be,
|
|
355
|
+
* or one signed-in lookup populates an entry every guest reads.
|
|
356
|
+
*/
|
|
357
|
+
PRICE_INDEX_MARKET: (ids) => `/api/tools/price-index/market?ids=${ids.map(encodeURIComponent).join(",")}`,
|
|
351
358
|
};
|
|
352
359
|
/**
|
|
353
360
|
* The sticker-sheet kiss-cut tracer. Admin-only, every route behind
|
|
@@ -27,6 +27,7 @@ import { RelatedProducts } from "../../products/components/RelatedProducts.js";
|
|
|
27
27
|
import { ProductGalleryClient } from "../../products/components/ProductGalleryClient.js";
|
|
28
28
|
import { ProductFeatureBadges } from "../../products/components/ProductFeatureBadges.js";
|
|
29
29
|
import { FeatureBadgeList } from "../../products/components/FeatureBadge.js";
|
|
30
|
+
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
30
31
|
import { HistoryTracker } from "../../history/components/HistoryTracker.js";
|
|
31
32
|
import { ShareButton } from "../../products/components/ShareButton.js";
|
|
32
33
|
import { MarketplaceAuctionGrid } from "./MarketplaceAuctionGrid.js";
|
|
@@ -149,8 +150,12 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
149
150
|
? p.price
|
|
150
151
|
: 0;
|
|
151
152
|
const minBidIncrementOverride = typeof p.minBidIncrement === "number" ? p.minBidIncrement : undefined;
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
// `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
|
|
154
|
+
// normaliser (it also accepts the legacy `string[]`). Casting it to
|
|
155
|
+
// `string[]` handed the gallery objects and rendered a placeholder.
|
|
156
|
+
const galleryUrls = productImageUrls(p.images);
|
|
157
|
+
const images = galleryUrls.length > 0
|
|
158
|
+
? galleryUrls
|
|
154
159
|
: typeof p.mainImage === "string"
|
|
155
160
|
? [p.mainImage]
|
|
156
161
|
: [];
|
|
@@ -206,11 +211,7 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
206
211
|
});
|
|
207
212
|
return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "auction", snapshot: {
|
|
208
213
|
title: typeof p.title === "string" ? p.title : undefined,
|
|
209
|
-
thumb: typeof p.mainImage === "string"
|
|
210
|
-
? p.mainImage
|
|
211
|
-
: Array.isArray(p.images) && typeof p.images[0] === "string"
|
|
212
|
-
? p.images[0]
|
|
213
|
-
: undefined,
|
|
214
|
+
thumb: typeof p.mainImage === "string" ? p.mainImage : images[0],
|
|
214
215
|
price: typeof p.price === "number" ? p.price : undefined,
|
|
215
216
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
216
217
|
storeName: typeof p.storeName === "string" ? p.storeName : undefined,
|
|
@@ -43,30 +43,7 @@ import { getProviders } from "../../../../../contracts/index.js";
|
|
|
43
43
|
import { categoriesRepository } from "../../../repository/categories.repository.js";
|
|
44
44
|
import { isAdminUser } from "../../../../auth/role-predicates.js";
|
|
45
45
|
import { priceGuidanceOnSellerBasis, describePriceGuidanceBasis, } from "../../../../../_internal/shared/features/categories/price-guidance.js";
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Nearest-first ancestor walk for `priceGuidance`, same shape as
|
|
49
|
-
* `description-template/route.ts`'s `resolveInherited` — the leaf's own band
|
|
50
|
-
* wins, and only a leaf with none falls back to the nearest ancestor that has
|
|
51
|
-
* one. ONE bounded `findByIds`, not a sequential walk: a seller form hitting
|
|
52
|
-
* this on every category change should not pay up to eight round trips for it.
|
|
53
|
-
*/
|
|
54
|
-
async function resolveInheritedGuidance(leaf) {
|
|
55
|
-
if (leaf.productDefaults?.priceGuidance)
|
|
56
|
-
return leaf.productDefaults.priceGuidance;
|
|
57
|
-
const parentIds = (leaf.parentIds ?? []).slice(0, MAX_ANCESTORS);
|
|
58
|
-
if (parentIds.length === 0)
|
|
59
|
-
return undefined;
|
|
60
|
-
const nearestFirst = [...parentIds].reverse();
|
|
61
|
-
const ancestors = await categoriesRepository.findByIds(nearestFirst);
|
|
62
|
-
const byId = new Map(ancestors.map((a) => [a.id, a]));
|
|
63
|
-
for (const pid of nearestFirst) {
|
|
64
|
-
const guidance = byId.get(pid)?.productDefaults?.priceGuidance;
|
|
65
|
-
if (guidance)
|
|
66
|
-
return guidance;
|
|
67
|
-
}
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
46
|
+
import { resolveInheritedGuidance, } from "../../../../../_internal/server/features/categories/price-guidance-resolve.js";
|
|
70
47
|
export const GET = createRouteHandler({
|
|
71
48
|
roles: ["seller", "admin", "moderator"],
|
|
72
49
|
handler: async ({ user, params }) => {
|
|
@@ -60,7 +60,7 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
60
60
|
// therefore never hide, however empty the brand was.
|
|
61
61
|
brandName
|
|
62
62
|
? listingTabCounts(CATEGORY_PAGE_TABS, { brandName })
|
|
63
|
-
: Promise.resolve({}),
|
|
63
|
+
: Promise.resolve({ available: {}, total: {} }),
|
|
64
64
|
// SB-UNI-D — bundles are categoryType:"bundle" rows on the categories
|
|
65
65
|
// collection, tagged to a brand via the real brandSlug field (added
|
|
66
66
|
// 2026-08-21 — previously a fragile seo.keywords string-match heuristic).
|
|
@@ -108,15 +108,23 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
108
108
|
// Keyed by tabSlug. `bundles` is overridden with the brand-scoped figure —
|
|
109
109
|
// `listingTabCounts` cannot narrow bundles by brand (a bundle's brand lives on
|
|
110
110
|
// `brandSlug`, which this page already filters on locally).
|
|
111
|
+
// Badge = available scope; hiding + the header pill = total. See the same
|
|
112
|
+
// split in `CategoryDetailPageView` and `ListingTabCountResult`.
|
|
111
113
|
const counts = {
|
|
112
|
-
...tabCounts,
|
|
113
|
-
products: tabCounts.products ?? brand?.metrics?.productCount ?? 0,
|
|
114
|
-
auctions: tabCounts.auctions ?? brand?.metrics?.auctionCount ?? 0,
|
|
114
|
+
...tabCounts.available,
|
|
115
|
+
products: tabCounts.available.products ?? brand?.metrics?.productCount ?? 0,
|
|
116
|
+
auctions: tabCounts.available.auctions ?? brand?.metrics?.auctionCount ?? 0,
|
|
117
|
+
bundles: brandBundles.length,
|
|
118
|
+
};
|
|
119
|
+
const totals = {
|
|
120
|
+
...tabCounts.total,
|
|
121
|
+
products: tabCounts.total.products ?? brand?.metrics?.productCount ?? 0,
|
|
122
|
+
auctions: tabCounts.total.auctions ?? brand?.metrics?.auctionCount ?? 0,
|
|
115
123
|
bundles: brandBundles.length,
|
|
116
124
|
};
|
|
117
125
|
// Header total — `?? 0` is right here (a pill just doesn't render), unlike the
|
|
118
126
|
// tab bar where an unknown count must keep its tab visible.
|
|
119
|
-
const totalItems = Object.values(
|
|
127
|
+
const totalItems = Object.values(totals).reduce((sum, n) => sum + (n ?? 0), 0);
|
|
120
128
|
/*
|
|
121
129
|
* ── D4: structured data on the brand page ───────────────────────────────
|
|
122
130
|
*
|
|
@@ -168,5 +176,5 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
168
176
|
const ldFaq = brand?.faqs && brand.faqs.length > 0
|
|
169
177
|
? faqJsonLd(brand.faqs.map((f) => ({ question: f.question, answer: f.answer })))
|
|
170
178
|
: null;
|
|
171
|
-
return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && (_jsx(DynamicBgDiv, { color: brandColor, className: "absolute inset-0 opacity-10" })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.BRANDS), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Brands" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: brand?.name ?? slug })] }), _jsxs(Row, { className: "mb-3", align: "center", gap: "md", children: [brand?.display?.icon && (_jsx(Span, { className: "leading-none", size: "5xl", children: brand.display.icon })), _jsxs(_Fragment, { children: [_jsx(Heading, { color: "inverse", level: 1, className: `${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: brand?.name ?? slug }), brand?.description && typeof brand.description === "string" && !brand.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mt-1 ${hasCover ? "/80" : ""}`, size: "base", children: brand.description }))] })] }), _jsxs(Row, { gap: "sm", wrap: true, className: "mt-3", children: [(counts.products ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: `${hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400"}`, rounded: "full", padding: "pill-sm-tall", children: [counts.products.toLocaleString(), " ", counts.products === 1 ? "product" : "products"] })), (counts.auctions ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.auctions.toLocaleString(), " ", counts.auctions === 1 ? "auction" : "auctions"] })), (counts["pre-orders"] ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts["pre-orders"].toLocaleString(), " ", counts["pre-orders"] === 1 ? "pre-order" : "pre-orders"] })), totalItems === 0 && (_jsx(Span, { size: "sm", className: hasCover ? "text-white/60" : "text-zinc-400", children: "No items listed yet" }))] })] })] }), (brand?.brandWebsite || brand?.brandCountry || brand?.brandFounded) && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", padding: "y-md", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "muted", transform: "uppercase", children: "About this brand" }), _jsxs(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden max-w-lg", children: [brand?.brandWebsite && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Website" }), _jsx(Dd, { className: "flex-1", children: _jsx(Anchor, { href: brand.brandWebsite, tone: "brand", underline: "hover", children: brand.brandWebsite }) })] })), brand?.brandCountry && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Country" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandCountry })] })), brand?.brandFounded && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Founded" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandFounded })] }))] })] }) }) })), brandContentBody && brandContentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: brandContentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: brand?.highlights, faqs: brand?.faqs }), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: brandName ? (_jsx(BrandDetailTabs, { brandName: brandName, initialProductsData: productsResult ?? undefined, initialBundles: brandBundles, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) })) : (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "Brand not found." })) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedBrands.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Brands" }), _jsx(CategoryGrid, { categories: relatedBrands, getHref: (b) => String(ROUTES.PUBLIC.BRAND_DETAIL(b.slug)) })] }) }))] }));
|
|
179
|
+
return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), !hasCover && (_jsx(DynamicBgDiv, { color: brandColor, className: "absolute inset-0 opacity-10" })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.BRANDS), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 dark:hover:text-primary-400 transition-colors", children: "Brands" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: brand?.name ?? slug })] }), _jsxs(Row, { className: "mb-3", align: "center", gap: "md", children: [brand?.display?.icon && (_jsx(Span, { className: "leading-none", size: "5xl", children: brand.display.icon })), _jsxs(_Fragment, { children: [_jsx(Heading, { color: "inverse", level: 1, className: `${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: brand?.name ?? slug }), brand?.description && typeof brand.description === "string" && !brand.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mt-1 ${hasCover ? "/80" : ""}`, size: "base", children: brand.description }))] })] }), _jsxs(Row, { gap: "sm", wrap: true, className: "mt-3", children: [(counts.products ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: `${hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400"}`, rounded: "full", padding: "pill-sm-tall", children: [counts.products.toLocaleString(), " ", counts.products === 1 ? "product" : "products"] })), (counts.auctions ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts.auctions.toLocaleString(), " ", counts.auctions === 1 ? "auction" : "auctions"] })), (counts["pre-orders"] ?? 0) > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [counts["pre-orders"].toLocaleString(), " ", counts["pre-orders"] === 1 ? "pre-order" : "pre-orders"] })), totalItems === 0 && (_jsx(Span, { size: "sm", className: hasCover ? "text-white/60" : "text-zinc-400", children: "No items listed yet" }))] })] })] }), (brand?.brandWebsite || brand?.brandCountry || brand?.brandFounded) && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", padding: "y-md", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "sm", weight: "semibold", color: "muted", transform: "uppercase", children: "About this brand" }), _jsxs(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden max-w-lg", children: [brand?.brandWebsite && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Website" }), _jsx(Dd, { className: "flex-1", children: _jsx(Anchor, { href: brand.brandWebsite, tone: "brand", underline: "hover", children: brand.brandWebsite }) })] })), brand?.brandCountry && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Country" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandCountry })] })), brand?.brandFounded && (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-32 flex-shrink-0", color: "primary", weight: "medium", children: "Founded" }), _jsx(Dd, { className: "flex-1", color: "muted", children: brand.brandFounded })] }))] })] }) }) })), brandContentBody && brandContentBody.trim().length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(RichTextRenderer, { html: brandContentBody }) }) })), _jsx(CategoryHighlightsAndFaqSection, { highlights: brand?.highlights, faqs: brand?.faqs }), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: brandName ? (_jsx(BrandDetailTabs, { brandName: brandName, initialProductsData: productsResult ?? undefined, initialBundles: brandBundles, counts: counts, totals: totals, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) })) : (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "Brand not found." })) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedBrands.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Brands" }), _jsx(CategoryGrid, { categories: relatedBrands, getHref: (b) => String(ROUTES.PUBLIC.BRAND_DETAIL(b.slug)) })] }) }))] }));
|
|
172
180
|
}
|
|
@@ -10,10 +10,16 @@ export interface BrandDetailTabsProps {
|
|
|
10
10
|
* inventory (Root Cause #59). Keyed by tab id; the previous hand-named shape
|
|
11
11
|
* covered only 5 of 9 tabs (Root Cause #61).
|
|
12
12
|
*/
|
|
13
|
+
/** Badge counts — what the default (available) scope will render. */
|
|
13
14
|
counts?: Record<string, number | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* Every published row per tab, all availability scopes. Drives hide-at-zero
|
|
17
|
+
* and the "N ended — show all" hint; see `ListingTabCountResult`.
|
|
18
|
+
*/
|
|
19
|
+
totals?: Record<string, number | undefined>;
|
|
14
20
|
/** Enabled listing types (e.g. ["standard","auction"]). When omitted, all tabs shown. */
|
|
15
21
|
enabledListingTypes?: string[];
|
|
16
22
|
/** Enabled category types (e.g. ["category","brand","bundle"]). When omitted, all tabs shown. */
|
|
17
23
|
enabledCategoryTypes?: string[];
|
|
18
24
|
}
|
|
19
|
-
export declare function BrandDetailTabs({ brandName, initialProductsData, initialBundles, counts, enabledListingTypes, enabledCategoryTypes, }: BrandDetailTabsProps): React.JSX.Element;
|
|
25
|
+
export declare function BrandDetailTabs({ brandName, initialProductsData, initialBundles, counts, totals, enabledListingTypes, enabledCategoryTypes, }: BrandDetailTabsProps): React.JSX.Element;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { EmptyState, Tabs, TabsList, TabsTrigger } from "../../../ui/index.js";
|
|
5
|
+
import { AvailabilityScopeHint } from "../../products/components/AvailabilityScopeHint.js";
|
|
5
6
|
import { CategoryProductsListing } from "./CategoryProductsListing.js";
|
|
6
7
|
import { AuctionsIndexListing } from "../../products/components/AuctionsIndexListing.js";
|
|
7
8
|
import { PreOrdersIndexListing } from "../../pre-orders/components/PreOrdersIndexListing.js";
|
|
@@ -28,6 +29,18 @@ const TAB_TYPE_MAP = {
|
|
|
28
29
|
])),
|
|
29
30
|
bundles: { kind: "category", type: "bundle" },
|
|
30
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The listing types a tab spans, for the availability-scope hint. Derived
|
|
34
|
+
* from the same maps the tabs render from, never hand-written (Root Cause
|
|
35
|
+
* #61).
|
|
36
|
+
*/
|
|
37
|
+
function typesForTab(id) {
|
|
38
|
+
const generic = GENERIC_TAB_LISTING_TYPES[id];
|
|
39
|
+
if (generic)
|
|
40
|
+
return generic;
|
|
41
|
+
const mapping = TAB_TYPE_MAP[id];
|
|
42
|
+
return mapping?.kind === "listing" ? [mapping.type] : [];
|
|
43
|
+
}
|
|
31
44
|
/** Listing types rendered by the shared products listing rather than a dedicated one. */
|
|
32
45
|
const GENERIC_TAB_LISTING_TYPES = {
|
|
33
46
|
[pluginFor("standard").tabSlug]: ["standard"],
|
|
@@ -46,8 +59,9 @@ const GENERIC_TAB_LISTING_TYPES = {
|
|
|
46
59
|
*/
|
|
47
60
|
[pluginFor("spares").tabSlug]: ["spares"],
|
|
48
61
|
};
|
|
49
|
-
export function BrandDetailTabs({ brandName, initialProductsData, initialBundles = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
|
|
62
|
+
export function BrandDetailTabs({ brandName, initialProductsData, initialBundles = [], counts, totals, enabledListingTypes, enabledCategoryTypes, }) {
|
|
50
63
|
const countFor = (id) => counts?.[id];
|
|
64
|
+
const totalFor = (id) => totals?.[id] ?? counts?.[id];
|
|
51
65
|
const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
|
|
52
66
|
const mapping = TAB_TYPE_MAP[t.id];
|
|
53
67
|
// A tab with no mapping has no render branch below and would show a blank
|
|
@@ -67,7 +81,8 @@ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles
|
|
|
67
81
|
// Hide a tab only when its count is known and explicitly zero. `undefined`
|
|
68
82
|
// now means the count query FAILED (every tab is counted), so the tab stays
|
|
69
83
|
// visible rather than hiding real listings behind a swallowed error.
|
|
70
|
-
|
|
84
|
+
// TOTAL, not the badge — see the same note in CategoryDetailTabs.
|
|
85
|
+
const count = totalFor(t.id);
|
|
71
86
|
return count === undefined || count > 0;
|
|
72
87
|
});
|
|
73
88
|
const firstTabId = (visibleTabs[0]?.id ?? "products");
|
|
@@ -75,5 +90,5 @@ export function BrandDetailTabs({ brandName, initialProductsData, initialBundles
|
|
|
75
90
|
if (visibleTabs.length === 0) {
|
|
76
91
|
return (_jsx(EmptyState, { title: "Nothing listed here yet", description: "This brand has no active listings right now. Check back soon." }));
|
|
77
92
|
}
|
|
78
|
-
return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: "", brandName: brandName, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { brandName: brandName })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { brandName: brandName })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { brandName: brandName })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles, brandName: brandName }))] }));
|
|
93
|
+
return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), _jsx(AvailabilityScopeHint, { total: totalFor(activeTab), available: countFor(activeTab), types: typesForTab(activeTab) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: "", brandName: brandName, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { brandName: brandName })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { brandName: brandName })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { brandName: brandName })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles, brandName: brandName }))] }));
|
|
79
94
|
}
|
|
@@ -16,10 +16,11 @@ import { MediaImage } from "../../media/MediaImage.js";
|
|
|
16
16
|
import { ImageLightbox } from "../../../ui/components/ImageLightbox.js";
|
|
17
17
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
18
18
|
import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
19
|
+
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
19
20
|
const PLACEHOLDER_EMOJI = "📦";
|
|
20
21
|
function toGalleryImages(members, canSeePrices) {
|
|
21
22
|
return members.map((p, i) => ({
|
|
22
|
-
src: p.mainImage
|
|
23
|
+
src: p.mainImage || productImageUrls(p.images)[0] || "",
|
|
23
24
|
alt: p.title,
|
|
24
25
|
badge: `#${i + 1}`,
|
|
25
26
|
caption: p.title,
|
|
@@ -46,7 +47,7 @@ export function BundleCollage({ members, onItemClick }) {
|
|
|
46
47
|
return null;
|
|
47
48
|
const galleryImages = toGalleryImages(members, canSeePrices);
|
|
48
49
|
return (_jsxs(_Fragment, { children: [_jsx(Grid, { cols: "categoryCards", gap: "3", children: members.map((p, idx) => {
|
|
49
|
-
const cover = p.mainImage
|
|
50
|
+
const cover = p.mainImage || productImageUrls(p.images)[0];
|
|
50
51
|
const href = String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#");
|
|
51
52
|
return (_jsxs(Div, { layout: "flex-col", className: "group relative overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] transition-transform hover:scale-[1.01]", rounded: "lg", children: [_jsx(Button, { variant: "ghost", type: "button", onClick: makeBundleItemClickHandler(p, idx, onItemClick, setLightboxIndex), paddingX: "none", paddingY: "none", rounded: "none", className: "relative aspect-square w-full overflow-hidden focus-visible:ring-2 focus-visible:ring-[var(--appkit-color-primary)]", "aria-label": `View ${p.title} in lightbox`, children: _jsxs(Div, { className: "relative aspect-square w-full", children: [cover ? (_jsx(MediaImage, { src: cover, alt: p.title, size: "card", className: "transition-transform group-hover:scale-105" })) : (_jsx(Row, { textSize: "3xl", surface: "muted", className: "absolute inset-0", align: "center", justify: "center", children: PLACEHOLDER_EMOJI })), _jsxs(Div, { textWeight: "semibold", textSize: "xs", surface: "overlay-xl", padding: "chip-2xs", className: "absolute left-2 top-2 text-white", rounded: "default", children: ["#", idx + 1] })] }) }), _jsxs(Div, { padding: "xs", children: [_jsx(Link, { href: href, className: "line-clamp-2 text-[length:var(--appkit-text-sm)] font-medium hover:underline", children: p.title }), _jsx(Text, { className: "mt-0.5 text-[var(--appkit-color-text-muted)]", size: "xs", children: _jsx(GatedPrice, { children: formatCurrency(p.price ?? 0, p.currency ?? "INR") }) })] })] }, p.id));
|
|
52
53
|
}) }), _jsx(ImageLightbox, { images: galleryImages, activeIndex: lightboxIndex, onClose: () => setLightboxIndex(null), onNavigate: (i) => setLightboxIndex(i), showThumbnails: true })] }));
|
|
@@ -18,6 +18,7 @@ import { Badge, Button, Checkbox, Div, Input, Row, Stack, Text } from "../../../
|
|
|
18
18
|
import { BUNDLE_MAX_ITEMS, BUNDLE_MIN_ITEMS, } from "../../../_internal/shared/features/categories/bundle-config.js";
|
|
19
19
|
import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy.js";
|
|
20
20
|
import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
21
|
+
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
21
22
|
const __P = {
|
|
22
23
|
p3: "p-[var(--appkit-space-3)]",
|
|
23
24
|
};
|
|
@@ -137,11 +138,9 @@ export async function defaultBundleItemsFetch(query) {
|
|
|
137
138
|
return {
|
|
138
139
|
id,
|
|
139
140
|
title,
|
|
140
|
-
image: typeof rec.mainImage === "string"
|
|
141
|
+
image: typeof rec.mainImage === "string" && rec.mainImage
|
|
141
142
|
? rec.mainImage
|
|
142
|
-
:
|
|
143
|
-
? rec.images[0]
|
|
144
|
-
: undefined,
|
|
143
|
+
: productImageUrls(rec.images)[0],
|
|
145
144
|
price: typeof rec.price === "number" ? rec.price : undefined,
|
|
146
145
|
storeId: typeof rec.storeId === "string" ? rec.storeId : undefined,
|
|
147
146
|
};
|