@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
|
@@ -15,6 +15,7 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names.js";
|
|
|
15
15
|
import { annotate } from "../../shell/field-ui-meta.js";
|
|
16
16
|
import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell/index.js";
|
|
17
17
|
import { applyZodIssues } from "../../../ui/forms/apply-zod-issues.js";
|
|
18
|
+
import { productImageUrls } from "../schemas/firestore.js";
|
|
18
19
|
const groupTitleSchema = z.object({
|
|
19
20
|
groupTitle: annotate(z.string().min(1, "Group title is required"), {
|
|
20
21
|
section: "group", sectionLabel: "Group", sectionRequired: true, quick: true,
|
|
@@ -351,7 +352,7 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
|
|
|
351
352
|
setOpen(next);
|
|
352
353
|
if (next && isGroupParent && children === null)
|
|
353
354
|
loadChildren();
|
|
354
|
-
}, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: titleSections, values: titleDraft, onChange: (partial) => setTitleDraft((d) => ({ ...d, ...partial })), onSubmit: () => void saveTitle(), onValidationChange: () => titleForm.validate(titleDraft), schema: groupTitleSchema, openIds: titleNav.openIds, onOpenChange: titleNav.setOpenIds, submitLabel: "Save title", isLoading: loading })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: "Selection rules" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(FieldInput, { name: "groupMinMembers", label: "Min items in a selection", type: "number", value: ruleDraft.min, onChange: (v) => setRuleDraft((d) => ({ ...d, min: v })), hint: "Blank = no minimum" }), _jsx(FieldInput, { name: "groupMaxMembers", label: "Max items in a selection", type: "number", value: ruleDraft.max, onChange: (v) => setRuleDraft((d) => ({ ...d, max: v })), hint: "Blank = no maximum" }), _jsx(FieldInput, { name: "groupMaxUnitsPerMember", label: "Max units of any one item", type: "number", value: ruleDraft.perMember, onChange: (v) => setRuleDraft((d) => ({ ...d, perMember: v })), hint: "Blank = no per-item cap" }), _jsx(FieldInput, { name: "groupDiscountPercent", label: "Group discount (%)", type: "number", value: ruleDraft.discount, onChange: (v) => setRuleDraft((d) => ({ ...d, discount: v })), hint: "Off the selection subtotal. Applies to new cart lines; a line already in a cart keeps the price it was quoted and is re-checked at checkout." })] }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", className: "mt-2", onClick: () => void saveRules(), isLoading: loading, children: "Save rules" })] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), _jsx(Text, { className: "mb-2", color: "faint", size: "xs", children: "Each listing has its own stock. Editing here saves them together \u2014 it does not pool them." }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [child.images
|
|
355
|
+
}, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: titleSections, values: titleDraft, onChange: (partial) => setTitleDraft((d) => ({ ...d, ...partial })), onSubmit: () => void saveTitle(), onValidationChange: () => titleForm.validate(titleDraft), schema: groupTitleSchema, openIds: titleNav.openIds, onOpenChange: titleNav.setOpenIds, submitLabel: "Save title", isLoading: loading })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: "Selection rules" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(FieldInput, { name: "groupMinMembers", label: "Min items in a selection", type: "number", value: ruleDraft.min, onChange: (v) => setRuleDraft((d) => ({ ...d, min: v })), hint: "Blank = no minimum" }), _jsx(FieldInput, { name: "groupMaxMembers", label: "Max items in a selection", type: "number", value: ruleDraft.max, onChange: (v) => setRuleDraft((d) => ({ ...d, max: v })), hint: "Blank = no maximum" }), _jsx(FieldInput, { name: "groupMaxUnitsPerMember", label: "Max units of any one item", type: "number", value: ruleDraft.perMember, onChange: (v) => setRuleDraft((d) => ({ ...d, perMember: v })), hint: "Blank = no per-item cap" }), _jsx(FieldInput, { name: "groupDiscountPercent", label: "Group discount (%)", type: "number", value: ruleDraft.discount, onChange: (v) => setRuleDraft((d) => ({ ...d, discount: v })), hint: "Off the selection subtotal. Applies to new cart lines; a line already in a cart keeps the price it was quoted and is re-checked at checkout." })] }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", className: "mt-2", onClick: () => void saveRules(), isLoading: loading, children: "Save rules" })] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), _jsx(Text, { className: "mb-2", color: "faint", size: "xs", children: "Each listing has its own stock. Editing here saves them together \u2014 it does not pool them." }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [productImageUrls(child.images)[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: productImageUrls(child.images)[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), child.status && child.status !== "published" ? (_jsx(Span, { size: "xs", surface: "warning-surface", className: "text-warning", padding: "pill-2xs", rounded: "default", children: child.status })) : null, (motDraft[child.id] ?? child.madeToOrder) === true ? (_jsx(Text, { size: "xs", color: "muted", children: "Made to order" })) : (_jsx(Input, { type: "number", "aria-label": `Stock for ${child.title}`, className: "w-20", value: stockDraft[child.id] ??
|
|
355
356
|
(child.availableQuantity ?? 0).toString(), onChange: (e) => setStockDraft((d) => ({ ...d, [child.id]: e.target.value })) })), _jsx(Checkbox, { label: "MTO", "aria-label": `Made to order for ${child.title}`, checked: motDraft[child.id] ?? child.madeToOrder === true, onChange: (e) => setMotDraft((d) => ({ ...d, [child.id]: e.target.checked })) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => requestUnlinkChild(child.id), isLoading: loading, className: `${CLS_DELETE_LINK} text-[length:var(--appkit-text-xs)]`, children: "Unlink" })] }, child.id)))] })), (children ?? []).length > 0 && (_jsx(Button, { type: "button", variant: "secondary", size: "sm", className: "mt-2", onClick: () => void saveGroupStock(), isLoading: loading, children: "Save group stock" }))] }), _jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestDissolveGroup, isLoading: loading, className: CLS_DELETE_LINK, children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", color: "muted", children: ["Part of:", " ", _jsx(Span, { weight: "medium", color: "primary", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { size: "xs", color: "muted", children: ["Parent listing: ", _jsx(Code, { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestLeaveGroup, isLoading: loading, className: `${CLS_DELETE_LINK} w-fit`, children: "Leave group" })] }))] })), (() => {
|
|
356
357
|
const content = (_jsx(AddChildContent, { addTab: addTab, setAddTab: setAddTab, createForm: createForm, setCreateForm: setCreateForm, productSlug: productSlug, storeProductsEndpoint: storeProductsEndpoint, linkTargets: linkTargets, setLinkTargets: setLinkTargets, onAddCreate: addCreateChild, onAddLink: addLinkChild, loading: loading }));
|
|
357
358
|
const close = () => setShowAddModal(false);
|
|
@@ -24,6 +24,7 @@ const CLS_BUNDLE_ROW = "flex items-center justify-between gap-[var(--appkit-spac
|
|
|
24
24
|
const CLS_BUNDLE_LABEL = "text-[length:var(--appkit-text-xs)] text-success dark:text-success font-medium uppercase tracking-wide mb-0.5";
|
|
25
25
|
const CLS_BUNDLE_CTA = "flex-shrink-0 rounded-lg bg-success-solid hover:brightness-110 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-success-on-solid transition-colors";
|
|
26
26
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
27
|
+
import { productImageUrls } from "../schemas/firestore.js";
|
|
27
28
|
import { PART_SLOT_LABEL } from "../../parts/schemas/firestore.js";
|
|
28
29
|
import { getModelsUsingPart } from "../../../_internal/server/features/parts/data.js";
|
|
29
30
|
import { ctaAnchorProps } from "../../layout/cta-anchor.js";
|
|
@@ -135,8 +136,12 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
135
136
|
const title = String(p.title ?? p.name ?? "");
|
|
136
137
|
const price = typeof p.price === "number" ? p.price : null;
|
|
137
138
|
const originalPrice = typeof p.originalPrice === "number" ? p.originalPrice : null;
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
// `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
|
|
140
|
+
// normaliser (it also accepts the legacy `string[]`). Casting it to
|
|
141
|
+
// `string[]` handed the gallery objects and rendered a placeholder.
|
|
142
|
+
const galleryUrls = productImageUrls(p.images);
|
|
143
|
+
const images = galleryUrls.length > 0
|
|
144
|
+
? galleryUrls
|
|
140
145
|
: typeof p.mainImage === "string"
|
|
141
146
|
? [p.mainImage]
|
|
142
147
|
: [];
|
|
@@ -9,6 +9,7 @@ import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
|
9
9
|
import { normalizeListingType } from "../utils/listing-type.js";
|
|
10
10
|
import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js";
|
|
11
11
|
import { SUBLISTING_CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
12
|
+
import { productImageUrls } from "../schemas/firestore.js";
|
|
12
13
|
const __O = {
|
|
13
14
|
hidden: "overflow-hidden",
|
|
14
15
|
xAuto: "overflow-x-auto",
|
|
@@ -18,7 +19,7 @@ function getHref(listing) {
|
|
|
18
19
|
return pluginFor(normalizeListingType(listing)).detailRoute(slug);
|
|
19
20
|
}
|
|
20
21
|
function ListingThumb({ listing, isCurrent, }) {
|
|
21
|
-
const image = listing.images
|
|
22
|
+
const image = productImageUrls(listing.images)[0] || listing.mainImage || "";
|
|
22
23
|
const href = getHref(listing);
|
|
23
24
|
const price = formatCurrency(listing.price, listing.currency ?? "INR");
|
|
24
25
|
return (_jsxs(Link, { href: href, "aria-label": listing.title, className: "flex flex-col items-center gap-[var(--appkit-space-1-5)] flex-shrink-0 w-16 group", children: [_jsx(Div, { rounded: "full", className: `relative w-14 h-14 overflow-hidden border-2 transition-all ${isCurrent
|
|
@@ -694,6 +694,7 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
694
694
|
status?: string;
|
|
695
695
|
categoriesIn?: string[];
|
|
696
696
|
search?: string;
|
|
697
|
+
brandName?: string;
|
|
697
698
|
}): Promise<FirebaseSieveResult<ProductDocument>>;
|
|
698
699
|
/**
|
|
699
700
|
* How many products of each listing type exist within a scope — the numbers
|
|
@@ -1451,7 +1451,11 @@ export class ProductRepository extends BaseRepository {
|
|
|
1451
1451
|
}
|
|
1452
1452
|
return baseQuery;
|
|
1453
1453
|
}
|
|
1454
|
-
async list(model,
|
|
1454
|
+
async list(model,
|
|
1455
|
+
// `brandName` was already honoured by `buildScopedQuery` and simply not
|
|
1456
|
+
// declared here, so a brand-scoped caller had to express the brand as a
|
|
1457
|
+
// Sieve clause instead — a second spelling of one filter.
|
|
1458
|
+
opts) {
|
|
1455
1459
|
/*
|
|
1456
1460
|
* 🛑 THE EMPTY GUARD — a non-blank query that yields NO usable token must
|
|
1457
1461
|
* return nothing, not everything.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProductImage } from "../schemas/firestore";
|
|
1
2
|
/**
|
|
2
3
|
* The thumbnail for one product-group member, from whichever shape the caller
|
|
3
4
|
* supplied.
|
|
@@ -33,7 +34,13 @@
|
|
|
33
34
|
export interface GroupMemberImageSource {
|
|
34
35
|
/** The public projection's single thumbnail. */
|
|
35
36
|
image?: string;
|
|
36
|
-
/**
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* A raw document's gallery. Only read when `image` is absent.
|
|
39
|
+
*
|
|
40
|
+
* 🛑 `ProductImage[]` since A6 — a raw document holds objects, a legacy one
|
|
41
|
+
* holds strings, and the public projection holds neither. `productImageUrls`
|
|
42
|
+
* is the one normaliser that spans all three.
|
|
43
|
+
*/
|
|
44
|
+
images?: readonly (string | ProductImage)[];
|
|
38
45
|
}
|
|
39
46
|
export declare function resolveGroupMemberImage(m: GroupMemberImageSource): string;
|
|
@@ -34,6 +34,7 @@ import { DataListingView } from "../../admin/components/DataListingView.js";
|
|
|
34
34
|
import { AssemblyDrawer, } from "../../parts/components/AssemblyDrawer.js";
|
|
35
35
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
|
|
36
36
|
import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers.js";
|
|
37
|
+
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
37
38
|
const FILTER_KEYS = [
|
|
38
39
|
"status",
|
|
39
40
|
"category",
|
|
@@ -579,8 +580,8 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
|
|
|
579
580
|
secondary: [priceLabel, stockLabel, secondaryBase, statusLabel].filter(Boolean).join(" · "),
|
|
580
581
|
status: statusLabel,
|
|
581
582
|
updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
|
|
582
|
-
imageUrl: toStringValue(item.mainImage
|
|
583
|
-
image: toStringValue(item.mainImage
|
|
583
|
+
imageUrl: toStringValue(item.mainImage, undefined) || productImageUrls(item.images)[0],
|
|
584
|
+
image: toStringValue(item.mainImage, undefined) || productImageUrls(item.images)[0],
|
|
584
585
|
listingKind: kind,
|
|
585
586
|
price: priceRaw ? `₹${priceRaw.toLocaleString("en-IN")}` : "—",
|
|
586
587
|
stock: stockLabel || "—",
|
|
@@ -16,6 +16,7 @@ import { AUCTION_PUBLIC_SORT_OPTIONS } from "../../products/constants/sieve.js";
|
|
|
16
16
|
import { AvailabilityTabs } from "../../products/components/AvailabilityTabs.js";
|
|
17
17
|
import { AVAILABILITY_VALUES } from "../../../constants/field-names.js";
|
|
18
18
|
import { TABLE_KEYS } from "../../../constants/table-keys.js";
|
|
19
|
+
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
19
20
|
const STORE_AUCTION_TYPES = ["auction"];
|
|
20
21
|
const __P = {
|
|
21
22
|
p3: "p-[var(--appkit-space-3)]",
|
|
@@ -75,7 +76,7 @@ export function StoreAuctionsListing({ storeId, initialData }) {
|
|
|
75
76
|
description: p.description,
|
|
76
77
|
price: p.price ?? 0,
|
|
77
78
|
currency: p.currency || getDefaultCurrency(),
|
|
78
|
-
mainImage: p.mainImage
|
|
79
|
+
mainImage: p.mainImage || productImageUrls(p.images)[0],
|
|
79
80
|
images: p.images,
|
|
80
81
|
listingType: "auction",
|
|
81
82
|
auctionEndDate: p.auctionEndDate,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* WHY: Authored six-part procedures for content-discovery/authenticity-checker.
|
|
3
|
-
* WHAT:
|
|
3
|
+
* WHAT: 22 cases, keyed by full checklist id.
|
|
4
4
|
*
|
|
5
5
|
* 🛑 EVERY CASE IS WRITTEN AS BEFORE → AFTER, because this feature's whole
|
|
6
6
|
* failure mode is a confident, plausible, wrong answer returned with HTTP 200. A
|
|
@@ -44,7 +44,7 @@ export const authored = {
|
|
|
44
44
|
startPage: "/tools/authenticity",
|
|
45
45
|
steps: [
|
|
46
46
|
"Open /tools/authenticity signed out.",
|
|
47
|
-
"Confirm the LOOKUP FORM itself is there and usable —
|
|
47
|
+
"Confirm the LOOKUP FORM itself is there and usable — 'Which bey is it?', 'Code on the box', a collapsed 'More details' holding the name-as-printed, brand, generation and how-it-was-sold fields, and a 'Check it' button.",
|
|
48
48
|
"Confirm there is no sign-in card and no 'Sign in to run a lookup' text anywhere on the page.",
|
|
49
49
|
"Enter A0813 as the code and submit, still signed out.",
|
|
50
50
|
"Confirm a real assessment comes back rather than an error or a prompt to sign in.",
|
|
@@ -475,4 +475,116 @@ export const authoredTypeahead = {
|
|
|
475
475
|
expectedUiState: "The result renders immediately. The network log shows no fetch to the search artifact or any /api/search* endpoint.",
|
|
476
476
|
endResult: "Read-only; nothing persists.",
|
|
477
477
|
},
|
|
478
|
+
/* ── The model picker and the per-model code list (2026-10-20) ──────────
|
|
479
|
+
* 🛑 The reported bug these exist for: typing "l drago" returned
|
|
480
|
+
* "Inconclusive — Nothing contradicts it, and nothing confirms it either",
|
|
481
|
+
* while the tool held 33 recorded box codes for Lightning L-Drago, 35 for
|
|
482
|
+
* Meteo and 27 for Destructor and had no way to reach any of them. Every
|
|
483
|
+
* table was keyed by CODE, so naming a bey could never surface them.
|
|
484
|
+
*
|
|
485
|
+
* So each case compares the answer against data the tool DEMONSTRABLY HAS.
|
|
486
|
+
* "Check the picker works" passes against the bug; "picking Lightning
|
|
487
|
+
* L-Drago must list its 33 codes" does not.
|
|
488
|
+
*/
|
|
489
|
+
"checklist-content-discovery-authenticity-checker-model-picker-finds-l-drago": {
|
|
490
|
+
roles: ["guest"],
|
|
491
|
+
startPage: "/tools/authenticity",
|
|
492
|
+
steps: [
|
|
493
|
+
"Open /tools/authenticity.",
|
|
494
|
+
"Confirm the FIRST field is 'Which bey is it?' and is a searchable select, not a plain text box.",
|
|
495
|
+
"Open it and type: l drago",
|
|
496
|
+
"Confirm all three L-Drago releases are offered — Lightning L-Drago 100HF, Meteo L-Drago LW105LF and L-Drago Destructor F:S.",
|
|
497
|
+
"Confirm each offered row states how many box codes are on file for it.",
|
|
498
|
+
"Clear the search and type: pegasis",
|
|
499
|
+
"Confirm Storm Pegasus still appears, even though the list spells it Pegasus.",
|
|
500
|
+
"Answer no if 'l drago' offers nothing, or if the field is a free-text input with no list.",
|
|
501
|
+
],
|
|
502
|
+
inputs: { search: "l drago" },
|
|
503
|
+
expectedBehaviour: "BEFORE: the first field was a free-text 'Model name'. Typing 'l drago' matched four releases, so the engine refused to pick one and returned an inconclusive verdict — correct, and useless, because the three L-Dragos it declined to choose between have 95 recorded box codes between them and the screen never said so. AFTER: the picker resolves exactly one bey, which is what unlocks the per-model code list. It searches aliases as well as labels, because Takara Tomy romanises the line Pegasis and Hasbro prints Pegasus — a label-only filter would find neither from the other.",
|
|
504
|
+
expectedUiState: "A searchable select labelled 'Which bey is it?' listing three L-Drago rows, each annotated with a box-code count. Beys with codes on file are offered before beys without.",
|
|
505
|
+
endResult: "Nothing is written. The picker only reads a bundled list.",
|
|
506
|
+
},
|
|
507
|
+
"checklist-content-discovery-authenticity-checker-picked-model-lists-its-box-codes": {
|
|
508
|
+
roles: ["guest"],
|
|
509
|
+
startPage: "/tools/authenticity",
|
|
510
|
+
steps: [
|
|
511
|
+
"Open /tools/authenticity.",
|
|
512
|
+
"In 'Which bey is it?', search for and pick: Lightning L-Drago 100HF",
|
|
513
|
+
"Leave 'Code on the box' EMPTY.",
|
|
514
|
+
"Press 'Check it'.",
|
|
515
|
+
"Read the verdict line, then look for a panel headed 'Box codes recorded for Lightning L-Drago 100HF'.",
|
|
516
|
+
"Count the codes it lists.",
|
|
517
|
+
"Answer no if the verdict is 'Inconclusive — Nothing contradicts it, and nothing confirms it either', or if no code list appears.",
|
|
518
|
+
],
|
|
519
|
+
inputs: { modelSlug: "lightning-l-drago-100hf" },
|
|
520
|
+
expectedBehaviour: "BEFORE: naming this bey with no code produced 'Inconclusive — Nothing contradicts it, and nothing confirms it either' and a physical checklist. True, in that nothing had been checked — and it withheld the 33 box codes the tables record for this exact bey, which is the single most useful thing the tool holds and the one thing the user had asked for. AFTER: the verdict is 'I know this one. Now find the code stamped on the box.' — still not a judgement, because nothing has been checked, but with the next step named and the 33 codes listed to check against.",
|
|
521
|
+
expectedUiState: "Verdict badge 'Need a bit more', headline 'I know this one. Now find the code stamped on the box.', a caveat naming the count, and a panel listing 33 codes as badges. A link reading 'Everything we hold on Lightning L-Drago 100HF' points at the model's own category page.",
|
|
522
|
+
expectedData: { recordedCodes: 33 },
|
|
523
|
+
endResult: "Read-only.",
|
|
524
|
+
},
|
|
525
|
+
"checklist-content-discovery-authenticity-checker-picked-model-plus-its-own-code": {
|
|
526
|
+
roles: ["guest"],
|
|
527
|
+
startPage: "/tools/authenticity",
|
|
528
|
+
steps: [
|
|
529
|
+
"Pick 'Lightning L-Drago 100HF' in 'Which bey is it?'.",
|
|
530
|
+
"Enter A0910 in 'Code on the box' — a code recorded against this exact bey.",
|
|
531
|
+
"Press 'Check it' and read the verdict.",
|
|
532
|
+
"Expand 'How I got there' and find the row about the code.",
|
|
533
|
+
"Press 'Start over'. Pick Lightning L-Drago 100HF again, this time enter A0310 — a real code recorded against a DIFFERENT bey.",
|
|
534
|
+
"Read the verdict and expand the working-out again.",
|
|
535
|
+
"Answer no if the second case reads as a mismatch, a discrepancy, or anything implying the item is wrong.",
|
|
536
|
+
],
|
|
537
|
+
inputs: { modelSlug: "lightning-l-drago-100hf", recordedCode: "A0910", otherBeyCode: "A0310" },
|
|
538
|
+
expectedBehaviour: "A0910 is in this bey's own list, so the check reads as agreement — the strongest thing the tool can say from data, and it still says so is not proof. A0310 is recorded, but on Earth Aquila: that is WEAK SUPPORT, not a mismatch, because a production date covers whatever was boxed that day. The distinction is the whole point. Reporting 'that code belongs to a different bey' as a discrepancy would manufacture suspicion out of how the tables are organised, and the panel highlights the code you entered inside the list so you can see where it sits.",
|
|
539
|
+
expectedUiState: "First run: 'Consistent with an original', and a row reading that the code is recorded on genuine boxes of this exact bey. Second run: still not adverse — a neutral row saying the code is recorded but not against this bey, with the phrase 'weak support rather than a mismatch'.",
|
|
540
|
+
endResult: "Read-only.",
|
|
541
|
+
},
|
|
542
|
+
"checklist-content-discovery-authenticity-checker-missing-code-is-not-an-accusation": {
|
|
543
|
+
roles: ["guest"],
|
|
544
|
+
startPage: "/tools/authenticity",
|
|
545
|
+
steps: [
|
|
546
|
+
"Pick 'Lightning L-Drago 100HF' in 'Which bey is it?'.",
|
|
547
|
+
"Enter Z9999 in 'Code on the box' — well-formed, and in none of the tables.",
|
|
548
|
+
"Press 'Check it'.",
|
|
549
|
+
"Read the verdict, the code panel and the working-out.",
|
|
550
|
+
"Answer no if any sentence implies the item is fake, counterfeit or a reproduction, or if the verdict is more severe than 'Inconclusive'.",
|
|
551
|
+
],
|
|
552
|
+
inputs: { modelSlug: "lightning-l-drago-100hf", code: "Z9999" },
|
|
553
|
+
expectedBehaviour: "A code absent from a model's list means UNRECORDED, never wrong. These tables are what collectors photographed, not a manufacturing record: 38 of 221 canonical beys have any recorded code at all, new genuine codes turn up regularly, and counterfeiters print real codes anyway. The panel must say so where a user will actually read it, not only inside the collapsed working-out — the reading 'my code is not in the list, so it is fake' is the one this whole feature has to defend against.",
|
|
554
|
+
expectedUiState: "Verdict 'Inconclusive'. A neutral row reading 'Not among the 33 codes recorded for Lightning L-Drago 100HF' whose text says 'unrecorded, not wrong'. The code panel carries a line beginning 'A code missing from this list means unrecorded, not fake.'",
|
|
555
|
+
endResult: "Read-only.",
|
|
556
|
+
},
|
|
557
|
+
"checklist-content-discovery-authenticity-checker-working-out-is-collapsed-by-default": {
|
|
558
|
+
roles: ["guest"],
|
|
559
|
+
startPage: "/tools/authenticity",
|
|
560
|
+
steps: [
|
|
561
|
+
"Pick 'Lightning L-Drago 100HF' and enter A0910. Press 'Check it'.",
|
|
562
|
+
"WITHOUT expanding anything, confirm you can read the verdict badge, the headline, the caveat and the recorded-code list.",
|
|
563
|
+
"Confirm the reasoning is behind a single collapsed control reading 'How I got there — N checks'.",
|
|
564
|
+
"Expand it and confirm every individual check is still there, each with its own confidence chip.",
|
|
565
|
+
"Press 'Start over'. Now enter the code A0813 with no model picked — a code the sources record as a midfake batch.",
|
|
566
|
+
"Confirm the working-out is ALREADY OPEN this time, without being clicked.",
|
|
567
|
+
"Answer no if the reasoning is expanded by default on the clean run, or collapsed on the midfake run.",
|
|
568
|
+
],
|
|
569
|
+
inputs: { modelSlug: "lightning-l-drago-100hf", cleanCode: "A0910", midfakeCode: "A0813" },
|
|
570
|
+
expectedBehaviour: "BEFORE: every check rendered expanded under a 'What I checked' heading — up to eleven rows of reasoning between the buyer and the answer they asked for. AFTER: it is collapsed, not deleted, because 'why should I believe this' has to stay one tap away for a tool whose entire posture is that it hedges and cites. A CONCERN forces it open: the one case where the reasoning IS the answer is the case where something is wrong, and the user who never expands it must not be the user who never finds out.",
|
|
571
|
+
expectedUiState: "Clean run: verdict block, code panel, a collapsed 'How I got there — N checks', then the physical checks. Midfake run: the same control, already open.",
|
|
572
|
+
endResult: "Read-only.",
|
|
573
|
+
},
|
|
574
|
+
"checklist-content-discovery-authenticity-checker-result-scrolls-into-view": {
|
|
575
|
+
roles: ["guest"],
|
|
576
|
+
startPage: "/tools/authenticity",
|
|
577
|
+
steps: [
|
|
578
|
+
"Resize the browser to a phone viewport (about 390 x 740) or use device emulation.",
|
|
579
|
+
"Pick 'Lightning L-Drago 100HF' and enter A0910.",
|
|
580
|
+
"Scroll so the 'Check it' button is visible, and note what is on screen.",
|
|
581
|
+
"Press 'Check it'.",
|
|
582
|
+
"Without scrolling yourself, confirm the verdict badge and headline are now on screen.",
|
|
583
|
+
"Answer no if the page does not move and the verdict is below the fold.",
|
|
584
|
+
],
|
|
585
|
+
inputs: { viewport: "390x740", modelSlug: "lightning-l-drago-100hf", code: "A0910" },
|
|
586
|
+
expectedBehaviour: "BEFORE: the form is taller than a phone viewport, so the answer rendered below the fold and pressing 'Check it' looked like it had done nothing — at the one moment this tool must not be ambiguous. AFTER: the result is scrolled into view on every fresh assessment, including the re-run a clarifying question triggers, and anchored at the top of the viewport rather than the middle so the badge and headline land where the eye already is.",
|
|
587
|
+
expectedUiState: "After submitting, the verdict badge and headline are visible without manual scrolling.",
|
|
588
|
+
endResult: "Read-only.",
|
|
589
|
+
},
|
|
478
590
|
};
|
|
@@ -3301,6 +3301,18 @@ const rawTesterChecklistItems = [
|
|
|
3301
3301
|
{ key: "regional-code-reads-as-regional", label: "A Korean or Middle-East code names its region instead of coming back unrecorded" },
|
|
3302
3302
|
{ key: "series-window-names-the-production-run", label: "A derivable date names its Metal-series production window, or names none outside all four" },
|
|
3303
3303
|
{ key: "temporal-context-for-an-unmatched-code", label: "An unmatched code reports what else the tables record from the same month, framed as context not a match" },
|
|
3304
|
+
/* ── The model picker and the per-model code list (2026-10-20) ───
|
|
3305
|
+
* The reported bug: naming a bey the tool holds 33 recorded box
|
|
3306
|
+
* codes for produced "Nothing contradicts it, and nothing confirms
|
|
3307
|
+
* it either" and never mentioned them. Every case here compares the
|
|
3308
|
+
* answer against data the tool demonstrably has.
|
|
3309
|
+
*/
|
|
3310
|
+
{ key: "model-picker-finds-l-drago", label: "The model picker finds all three L-Dragos by partial name, and each shows how many box codes are on file" },
|
|
3311
|
+
{ key: "picked-model-lists-its-box-codes", label: "Picking a model with no code returns the codes recorded for that exact bey, not a bare 'inconclusive'" },
|
|
3312
|
+
{ key: "picked-model-plus-its-own-code", label: "A code recorded against the picked bey reads as agreement; one recorded against a different bey reads as weak support, not a mismatch" },
|
|
3313
|
+
{ key: "missing-code-is-not-an-accusation", label: "A code absent from the picked model's list says 'unrecorded', never 'fake', and the verdict stays inconclusive" },
|
|
3314
|
+
{ key: "working-out-is-collapsed-by-default", label: "The verdict is readable without expanding anything, and a concern auto-opens the working-out" },
|
|
3315
|
+
{ key: "result-scrolls-into-view", label: "Submitting scrolls the verdict into view on a phone rather than leaving it below the fold" },
|
|
3304
3316
|
],
|
|
3305
3317
|
},
|
|
3306
3318
|
{
|
|
@@ -51,10 +51,44 @@ import { seedGallery } from "./_helpers/media.js";
|
|
|
51
51
|
import { PRODUCT_FIELDS } from "../constants/field-names.js";
|
|
52
52
|
import { withFinalSale } from "./_helpers/final-sale-fixtures.js";
|
|
53
53
|
import { withDerivedCategoryNames } from "./_helpers/category-names.js";
|
|
54
|
+
import { buildSearchTxt } from "../utils/search-txt.js";
|
|
54
55
|
const STORE_ID = "store-letitrip-official";
|
|
55
56
|
const STORE_NAME = "LetItRip Official";
|
|
56
57
|
const BRAND_SLUG = "brand-takara-tomy";
|
|
57
58
|
const BRAND = "Takara-Tomy";
|
|
59
|
+
/**
|
|
60
|
+
* Build the `searchTxt` prefix index for a row.
|
|
61
|
+
*
|
|
62
|
+
* 🛑 THIS FILE SHIPPED WITHOUT IT AND EVERY LISTING WAS UNSEARCHABLE.
|
|
63
|
+
*
|
|
64
|
+
* `searchTxt` is the ONLY field the public product search queries. A row
|
|
65
|
+
* without it is not ranked lower — it is absent. Measured on production
|
|
66
|
+
* before this fix: 12 of the 14 real listings had no `searchTxt` at all, and
|
|
67
|
+
* the only two that did were the auction and the pre-order, whose own files
|
|
68
|
+
* already applied a tokeniser. So `?q=aero` returned 1 of the 3 AeroPegasus
|
|
69
|
+
* products and `?q=beyblade` returned 2 of 14, while a nonsense control
|
|
70
|
+
* correctly returned 0 — the search was working perfectly against an index
|
|
71
|
+
* that was almost empty.
|
|
72
|
+
*
|
|
73
|
+
* CLAUDE.md documents this exact trap on this exact pipeline: a wrapper
|
|
74
|
+
* missed when a seed file is created, with no error anywhere, because an
|
|
75
|
+
* absent field is indistinguishable from "no match" at every layer above.
|
|
76
|
+
*
|
|
77
|
+
* 🛑 Applied LAST, after `withDerivedCategoryNames`. The category names are
|
|
78
|
+
* derived by that wrapper, so tokenising before it indexes `undefined` and
|
|
79
|
+
* silently loses every category term ("Basic Line", "Beyblade X Tops") from
|
|
80
|
+
* the index.
|
|
81
|
+
*/
|
|
82
|
+
function withSearchTokens(p) {
|
|
83
|
+
return {
|
|
84
|
+
...p,
|
|
85
|
+
searchTxt: buildSearchTxt([
|
|
86
|
+
p.title, p.description, p.brand, p.brandSlug,
|
|
87
|
+
p.categoryNames, p.tags, p.condition,
|
|
88
|
+
p.specifications?.map((s) => `${s.name} ${s.value}`),
|
|
89
|
+
]),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
58
92
|
const _raw = [
|
|
59
93
|
// ===== Standalone tops =====
|
|
60
94
|
{
|
|
@@ -438,4 +472,5 @@ export const productsBeybladeXSeedData = _raw
|
|
|
438
472
|
seedPurpose: "permanent",
|
|
439
473
|
}))
|
|
440
474
|
.map(withFinalSale)
|
|
441
|
-
.map(withDerivedCategoryNames)
|
|
475
|
+
.map(withDerivedCategoryNames)
|
|
476
|
+
.map(withSearchTokens);
|
package/dist/server.d.ts
CHANGED
|
@@ -289,6 +289,8 @@ export { processRefundAction, type ProcessRefundInput, } from "./_internal/serve
|
|
|
289
289
|
export { computeCheckoutFees, computePayoutDeduction, computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, } from "./_internal/shared/fees/calculator";
|
|
290
290
|
export { searchPriceIndex, getPriceIndexBandsForIds, PRICE_INDEX_SEARCH_LIMIT, PRICE_INDEX_MIN_QUERY_LENGTH } from "./_internal/server/features/price-index/search";
|
|
291
291
|
export type { PriceIndexSearchResponse, PriceIndexSearchResult, PriceIndexSearchResultBand, } from "./_internal/server/features/price-index/search";
|
|
292
|
+
export { getPriceIndexMarketForIds } from "./_internal/server/features/price-index/market";
|
|
293
|
+
export type { PriceIndexMarketRow } from "./_internal/server/features/price-index/market";
|
|
292
294
|
export { protectionAvailability, computeProtectionFees, adjudicateClaim, badgesFor, showsRiskNotice, showsSafetyBadge, returnDestination, rtoDestination, PROTECTION_KINDS, } from "./_internal/shared/features/shipping/protections";
|
|
293
295
|
export type { ProtectionKind, ProtectionAvailability, ProtectionTierInput, ProtectionUnavailableReason, ClaimFinding, ClaimOutcome, } from "./_internal/shared/features/shipping/protections";
|
|
294
296
|
export type { FeeCommissionRates, CheckoutFees, PayoutDeduction, CodHandlingFeeRates, WhatsAppNotifyFeeRates, GiftWrapFeeRates, ShipmentProtectionFeeRates, SaleProtectionFeeRates, } from "./_internal/shared/fees/calculator";
|
package/dist/server.js
CHANGED
|
@@ -846,6 +846,14 @@ export { computeCheckoutFees, computePayoutDeduction, computeCodHandlingFee, com
|
|
|
846
846
|
// client.ts (verify-entries would catch firebase-admin, but the import chain
|
|
847
847
|
// is the thing to keep clean — Root Cause #6).
|
|
848
848
|
export { searchPriceIndex, getPriceIndexBandsForIds, PRICE_INDEX_SEARCH_LIMIT, PRICE_INDEX_MIN_QUERY_LENGTH } from "./_internal/server/features/price-index/search.js";
|
|
849
|
+
/*
|
|
850
|
+
* The MARKET half of the same tool, and a separate export because it is a
|
|
851
|
+
* separate AUTHENTICATED endpoint — the competitor-derived band must not ride
|
|
852
|
+
* the public, edge-cached sold response. The full argument is in that module's
|
|
853
|
+
* own header; the short version is that merging them would either poison a
|
|
854
|
+
* public cache with viewer-dependent content or cost the sold half its cache.
|
|
855
|
+
*/
|
|
856
|
+
export { getPriceIndexMarketForIds } from "./_internal/server/features/price-index/market.js";
|
|
849
857
|
export { protectionAvailability, computeProtectionFees, adjudicateClaim, badgesFor, showsRiskNotice, showsSafetyBadge, returnDestination, rtoDestination, PROTECTION_KINDS, } from "./_internal/shared/features/shipping/protections.js";
|
|
850
858
|
// [SERVER-ONLY] Payout refund deduction action
|
|
851
859
|
export { applyRefundDeductionAction, } from "./_internal/server/features/payouts/actions.js";
|
package/package.json
CHANGED
package/scripts/seed-cli.mjs
CHANGED
|
@@ -387,6 +387,104 @@ const COLLECTION_MAP = {
|
|
|
387
387
|
* A root with no mapping yields NO franchise (living collectibles are not an
|
|
388
388
|
* IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
|
|
389
389
|
*/
|
|
390
|
+
/**
|
|
391
|
+
* The real, sellable catalogue — everything else in the product seed is demo
|
|
392
|
+
* furniture and is DROPPED HERE, before anything is written.
|
|
393
|
+
*
|
|
394
|
+
* ── 🛑 WHY A FILTER AND NOT A DELETION ────────────────────────────────────
|
|
395
|
+
*
|
|
396
|
+
* The fixtures stay in their files on purpose: they are the only coverage
|
|
397
|
+
* several features have (every `preOrderProductionStatus` value, a sold-out
|
|
398
|
+
* reservation, a depleted digital-code pool, an ended-unsold auction), and
|
|
399
|
+
* the tester checklist deep-links to them by id. Deleting them would silently
|
|
400
|
+
* remove that coverage. Not LOADING them is the part that was wanted.
|
|
401
|
+
*
|
|
402
|
+
* ── 🛑 WHY THIS HAD TO EXIST ──────────────────────────────────────────────
|
|
403
|
+
*
|
|
404
|
+
* `appkit-seed load` writes every registered row and `purge` deletes
|
|
405
|
+
* `seedPurpose: "test"` afterwards — so the demo listings came BACK on every
|
|
406
|
+
* reseed, and anyone who ran `load` to fix imagery silently republished 76
|
|
407
|
+
* fake products to the live storefront. That happened twice in one session.
|
|
408
|
+
* The owner's instruction was "delete them from the seed itself and make sure
|
|
409
|
+
* they do not return"; this is the point where that can be guaranteed, since
|
|
410
|
+
* every write path funnels through here.
|
|
411
|
+
*
|
|
412
|
+
* ── The discriminator is the STORE, not the listing type ──────────────────
|
|
413
|
+
*
|
|
414
|
+
* `store-beyblade-arena` is the fictional demo seller; `store-letitrip-
|
|
415
|
+
* official` is the real one. Keying on the store rather than on a per-file
|
|
416
|
+
* allow-list means a fixture added to ANY product seed file later is excluded
|
|
417
|
+
* automatically, which is the failure mode this is replacing — a list that
|
|
418
|
+
* has to be remembered is a list that drifts.
|
|
419
|
+
*
|
|
420
|
+
* Set `SEED_INCLUDE_DEMO=1` to load them anyway, for local demo work.
|
|
421
|
+
*/
|
|
422
|
+
const REAL_STORE_IDS = new Set(["store-letitrip-official"]);
|
|
423
|
+
|
|
424
|
+
function realListingsOnly(products) {
|
|
425
|
+
if (process.env.SEED_INCLUDE_DEMO === "1") {
|
|
426
|
+
console.log("▌ SEED_INCLUDE_DEMO=1 — demo listings INCLUDED");
|
|
427
|
+
return products;
|
|
428
|
+
}
|
|
429
|
+
/*
|
|
430
|
+
* 🛑 TWO rules, because the store alone is not enough. Six prize-draw
|
|
431
|
+
* fixtures live under the REAL store and are still demo furniture — their
|
|
432
|
+
* imagery is a 460-byte generated colour tile from /images/seed-tiles/,
|
|
433
|
+
* which is the giveaway: a listing nobody photographed is a listing nobody
|
|
434
|
+
* intends to sell. Measured before writing this: every one of the six, and
|
|
435
|
+
* nothing real, matches.
|
|
436
|
+
*/
|
|
437
|
+
const isPlaceholder = (p) => {
|
|
438
|
+
const urls = [p?.mainImage, ...(Array.isArray(p?.images) ? p.images : [])]
|
|
439
|
+
.map((v) => (typeof v === "string" ? v : v?.url))
|
|
440
|
+
.filter(Boolean);
|
|
441
|
+
return urls.length > 0 && urls.every((u) => String(u).includes("/images/seed-tiles/"));
|
|
442
|
+
};
|
|
443
|
+
const kept = products.filter((p) => REAL_STORE_IDS.has(p?.storeId) && !isPlaceholder(p));
|
|
444
|
+
const dropped = products.length - kept.length;
|
|
445
|
+
if (dropped > 0) {
|
|
446
|
+
console.log(` • ${dropped} demo listing(s) not loaded (set SEED_INCLUDE_DEMO=1 to include)`);
|
|
447
|
+
}
|
|
448
|
+
return kept;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Drop bundle categories whose members are not in the loaded catalogue.
|
|
453
|
+
*
|
|
454
|
+
* 🛑 A bundle is ALL-OR-NOTHING — `canAddToCart` buys every member together —
|
|
455
|
+
* so a bundle missing even one member is unsellable, and one missing ALL of
|
|
456
|
+
* them is a card that opens an empty page. Measured right after the demo
|
|
457
|
+
* listings stopped loading: of the five seeded bundles, three had zero
|
|
458
|
+
* surviving members and the other two had 1-of-4 and 2-of-3. All five were
|
|
459
|
+
* still `isActive: true`, and all five rendered on the HOMEPAGE as collage
|
|
460
|
+
* cards built from `display.mosaicImages` — which are baked at seed time from
|
|
461
|
+
* the members' own imagery, so they kept showing pictures of products that no
|
|
462
|
+
* longer exist. That is 92 placeholder-tile references on the homepage.
|
|
463
|
+
*
|
|
464
|
+
* Derived rather than hand-maintained for the same reason as the product
|
|
465
|
+
* filter: a bundle that becomes valid later starts loading again on its own,
|
|
466
|
+
* and one that breaks stops, with nobody having to remember either.
|
|
467
|
+
*
|
|
468
|
+
* Only `categoryType: "bundle"` rows are considered. Every other category is
|
|
469
|
+
* taxonomy and is never touched — rebuilding the tree is expensive and is
|
|
470
|
+
* explicitly not what this is for.
|
|
471
|
+
*/
|
|
472
|
+
function loadableBundlesOnly(categories, loadedProducts) {
|
|
473
|
+
const live = new Set(loadedProducts.map((p) => p?.id).filter(Boolean));
|
|
474
|
+
let dropped = 0;
|
|
475
|
+
const kept = categories.filter((c) => {
|
|
476
|
+
if (c?.categoryType !== "bundle") return true;
|
|
477
|
+
const ids = Array.isArray(c.bundleProductIds) ? c.bundleProductIds : [];
|
|
478
|
+
const ok = ids.length > 0 && ids.every((id) => live.has(id));
|
|
479
|
+
if (!ok) dropped++;
|
|
480
|
+
return ok;
|
|
481
|
+
});
|
|
482
|
+
if (dropped > 0) {
|
|
483
|
+
console.log(` • ${dropped} bundle(s) not loaded — members missing from the catalogue`);
|
|
484
|
+
}
|
|
485
|
+
return kept;
|
|
486
|
+
}
|
|
487
|
+
|
|
390
488
|
function withFranchise(products) {
|
|
391
489
|
let stamped = 0;
|
|
392
490
|
const out = products.map((p) => {
|
|
@@ -469,32 +567,13 @@ function withDerivedCategoryFields(products) {
|
|
|
469
567
|
return out;
|
|
470
568
|
}
|
|
471
569
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
* except the map that actually runs.
|
|
480
|
-
*/
|
|
481
|
-
payoutMethods: payoutMethodsSeedData,
|
|
482
|
-
shippingConfigs: shippingConfigsSeedData,
|
|
483
|
-
analyticsCards: analyticsCardsSeedData,
|
|
484
|
-
analyticsAlerts: analyticsAlertsSeedData,
|
|
485
|
-
storeCategories: storeCategoriesSeedData,
|
|
486
|
-
listingTemplates: listingTemplatesSeedData,
|
|
487
|
-
moderationQueue: moderationQueueSeedData,
|
|
488
|
-
reports: reportsSeedData,
|
|
489
|
-
itemRequests: itemRequestsSeedData,
|
|
490
|
-
storeGoogleConfig: storeGoogleConfigSeedData,
|
|
491
|
-
addresses: addressesSeedData,
|
|
492
|
-
storeAddresses: storeAddressesSeedData,
|
|
493
|
-
// SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
|
|
494
|
-
// standalone brands collection/seed exists anymore.
|
|
495
|
-
categories: categoriesSeedData,
|
|
496
|
-
stores: storesSeedData,
|
|
497
|
-
products: withFranchise(withDerivedCategoryFields([
|
|
570
|
+
/**
|
|
571
|
+
* The real catalogue, computed ONCE so the bundle filter and the product
|
|
572
|
+
* loader cannot disagree about what "loaded" means. Deriving it twice is
|
|
573
|
+
* Root Cause #75's shape — two copies of one rule, one of which learns about
|
|
574
|
+
* a change and the other does not.
|
|
575
|
+
*/
|
|
576
|
+
const REAL_PRODUCTS = realListingsOnly([
|
|
498
577
|
...(productsStandardSeedData || []),
|
|
499
578
|
// Real, wiki-verified Beyblade X listings under store-letitrip-official —
|
|
500
579
|
// added 2026-10-19 for PhonePe merchant registration, which requires at
|
|
@@ -536,7 +615,34 @@ const SEED_DATA_MAP = {
|
|
|
536
615
|
* what should happen to a leaf that now has BOTH a real listing and a
|
|
537
616
|
* generated one.
|
|
538
617
|
*/
|
|
539
|
-
])
|
|
618
|
+
]);
|
|
619
|
+
|
|
620
|
+
const SEED_DATA_MAP = {
|
|
621
|
+
users: usersSeedData,
|
|
622
|
+
/*
|
|
623
|
+
* store-extensions. COLLECTION_MAP was only HALF the wiring: load() reads
|
|
624
|
+
* SEED_DATA_MAP and short-circuits on an empty array, so adding the ten
|
|
625
|
+
* collection names alone produced "no seed data, skipping" for all ten.
|
|
626
|
+
* Same shape as the art/stickers note further down -- added everywhere
|
|
627
|
+
* except the map that actually runs.
|
|
628
|
+
*/
|
|
629
|
+
payoutMethods: payoutMethodsSeedData,
|
|
630
|
+
shippingConfigs: shippingConfigsSeedData,
|
|
631
|
+
analyticsCards: analyticsCardsSeedData,
|
|
632
|
+
analyticsAlerts: analyticsAlertsSeedData,
|
|
633
|
+
storeCategories: storeCategoriesSeedData,
|
|
634
|
+
listingTemplates: listingTemplatesSeedData,
|
|
635
|
+
moderationQueue: moderationQueueSeedData,
|
|
636
|
+
reports: reportsSeedData,
|
|
637
|
+
itemRequests: itemRequestsSeedData,
|
|
638
|
+
storeGoogleConfig: storeGoogleConfigSeedData,
|
|
639
|
+
addresses: addressesSeedData,
|
|
640
|
+
storeAddresses: storeAddressesSeedData,
|
|
641
|
+
// SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
|
|
642
|
+
// standalone brands collection/seed exists anymore.
|
|
643
|
+
categories: loadableBundlesOnly(categoriesSeedData, REAL_PRODUCTS),
|
|
644
|
+
stores: storesSeedData,
|
|
645
|
+
products: withFranchise(withDerivedCategoryFields(REAL_PRODUCTS)),
|
|
540
646
|
orders: ordersSeedData,
|
|
541
647
|
reviews: reviewsSeedData,
|
|
542
648
|
bids: bidsSeedData,
|