@mohasinac/appkit 4.50.0 → 4.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/franchises/data.d.ts +32 -0
- package/dist/_internal/server/features/franchises/data.js +33 -0
- package/dist/_internal/server/features/franchises/index.d.ts +2 -0
- package/dist/_internal/server/features/franchises/index.js +2 -0
- package/dist/_internal/server/features/franchises/og.d.ts +31 -0
- package/dist/_internal/server/features/franchises/og.js +11 -0
- package/dist/_internal/server/features/products/list-public.d.ts +9 -0
- package/dist/_internal/server/features/products/list-public.js +3 -0
- package/dist/constants/field-names.d.ts +3 -0
- package/dist/constants/field-names.js +11 -0
- package/dist/features/categories/components/FranchiseDetailPageView.d.ts +25 -0
- package/dist/features/categories/components/FranchiseDetailPageView.js +43 -0
- package/dist/features/categories/components/FranchisesIndexView.d.ts +14 -0
- package/dist/features/categories/components/FranchisesIndexView.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +15 -0
- package/dist/features/categories/repository/categories.repository.js +23 -0
- package/dist/features/categories/types/index.d.ts +1 -1
- package/dist/features/collections/schemas/index.d.ts +2 -2
- package/dist/features/pre-orders/schemas/index.d.ts +2 -2
- package/dist/features/products/repository/products.repository.d.ts +4 -0
- package/dist/features/products/repository/products.repository.js +5 -0
- package/dist/features/products/schemas/firestore.d.ts +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +9 -0
- package/dist/seed/_helpers/franchise-rows.d.ts +43 -0
- package/dist/seed/_helpers/franchise-rows.js +181 -0
- package/dist/seed/categories-seed-data.js +13 -0
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +32 -2
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.51.0",
|
|
4
|
+
"head": "8e22d42a3cf524b03f860462c5fe567bfbb621de",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
7
|
+
"builtAt": "2026-10-14T12:00:13.325Z"
|
|
8
8
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CategoryDocument } from "../../../../features/categories/schemas";
|
|
2
|
+
/**
|
|
3
|
+
* SSR reads for the FRANCHISE unit — what a product is FROM, where a brand is
|
|
4
|
+
* who made it.
|
|
5
|
+
*
|
|
6
|
+
* 🛑 Wrapped in `React.cache` because each of these is called by BOTH the page
|
|
7
|
+
* body and its `generateMetadata`. Without it that is two identical Firestore
|
|
8
|
+
* reads per render, on routes that are meant to be cheap (CLAUDE.md, "React.cache
|
|
9
|
+
* discipline").
|
|
10
|
+
*
|
|
11
|
+
* The argument list is the cache key, so these take the slug and nothing else —
|
|
12
|
+
* adding a viewer parameter here would give every call site its own entry and
|
|
13
|
+
* dedupe nothing.
|
|
14
|
+
*
|
|
15
|
+
* @tag domain:categories
|
|
16
|
+
* @tag layer:server
|
|
17
|
+
* @tag access:server-only
|
|
18
|
+
* @tag sideEffects:firestore-read
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* One franchise by slug.
|
|
22
|
+
*
|
|
23
|
+
* 🛑 Deliberately NOT filtered on `isActive`. Seven of the eight seeded
|
|
24
|
+
* franchises are inactive because they have no stock yet, and the entire point
|
|
25
|
+
* of seeding them is that `/franchise/marvel` resolves instead of 404ing — the
|
|
26
|
+
* eighteen `CharacterHotspot` links pointed at a route that did not exist. The
|
|
27
|
+
* grid is where inactive rows are hidden (`findActiveFranchises`); the detail
|
|
28
|
+
* page renders them as an honest "nothing here yet".
|
|
29
|
+
*/
|
|
30
|
+
export declare const getFranchiseForDetail: (slug: string) => Promise<CategoryDocument | null>;
|
|
31
|
+
/** Every franchise a visitor may browse — active only, so no dead facets. */
|
|
32
|
+
export declare const listActiveFranchises: () => Promise<CategoryDocument[]>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
2
|
+
import { categoriesRepository } from "../../../../features/categories/repository/categories.repository.js";
|
|
3
|
+
/**
|
|
4
|
+
* SSR reads for the FRANCHISE unit — what a product is FROM, where a brand is
|
|
5
|
+
* who made it.
|
|
6
|
+
*
|
|
7
|
+
* 🛑 Wrapped in `React.cache` because each of these is called by BOTH the page
|
|
8
|
+
* body and its `generateMetadata`. Without it that is two identical Firestore
|
|
9
|
+
* reads per render, on routes that are meant to be cheap (CLAUDE.md, "React.cache
|
|
10
|
+
* discipline").
|
|
11
|
+
*
|
|
12
|
+
* The argument list is the cache key, so these take the slug and nothing else —
|
|
13
|
+
* adding a viewer parameter here would give every call site its own entry and
|
|
14
|
+
* dedupe nothing.
|
|
15
|
+
*
|
|
16
|
+
* @tag domain:categories
|
|
17
|
+
* @tag layer:server
|
|
18
|
+
* @tag access:server-only
|
|
19
|
+
* @tag sideEffects:firestore-read
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* One franchise by slug.
|
|
23
|
+
*
|
|
24
|
+
* 🛑 Deliberately NOT filtered on `isActive`. Seven of the eight seeded
|
|
25
|
+
* franchises are inactive because they have no stock yet, and the entire point
|
|
26
|
+
* of seeding them is that `/franchise/marvel` resolves instead of 404ing — the
|
|
27
|
+
* eighteen `CharacterHotspot` links pointed at a route that did not exist. The
|
|
28
|
+
* grid is where inactive rows are hidden (`findActiveFranchises`); the detail
|
|
29
|
+
* page renders them as an honest "nothing here yet".
|
|
30
|
+
*/
|
|
31
|
+
export const getFranchiseForDetail = cache(async (slug) => categoriesRepository.findBySlugAndType(slug, "franchise"));
|
|
32
|
+
/** Every franchise a visitor may browse — active only, so no dead facets. */
|
|
33
|
+
export const listActiveFranchises = cache(async () => categoriesRepository.findActiveFranchises());
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* OG card for a FRANCHISE page.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Delegates to `renderBrandOgImage` rather than copying its layout. That
|
|
6
|
+
* renderer already takes a generic `{ name, description, logoUrl }` and a
|
|
7
|
+
* franchise card is the same shape as a brand card — a cover image, a title and
|
|
8
|
+
* a line of copy. Forking it would give the two surfaces independent drift for
|
|
9
|
+
* no visual difference, which is the duplication the Rule of Three exists to
|
|
10
|
+
* prevent (two copies is observation; this is the second).
|
|
11
|
+
*
|
|
12
|
+
* If a franchise card ever needs its own treatment, the split happens at THAT
|
|
13
|
+
* point, with a reason — not pre-emptively.
|
|
14
|
+
*
|
|
15
|
+
* @tag domain:categories
|
|
16
|
+
* @tag layer:server
|
|
17
|
+
* @tag access:server-only
|
|
18
|
+
*/
|
|
19
|
+
interface FranchiseDocLike {
|
|
20
|
+
name?: string | null;
|
|
21
|
+
description?: string | null;
|
|
22
|
+
/** Same field as brands — `CategoryDocument` has no dedicated logo field. */
|
|
23
|
+
display?: {
|
|
24
|
+
coverImage?: string | null;
|
|
25
|
+
} | null;
|
|
26
|
+
}
|
|
27
|
+
export declare function renderFranchiseOg(doc: FranchiseDocLike | null | undefined, opts: {
|
|
28
|
+
siteName: string;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
}): ReactElement;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { resolveOgImageUrl } from "../seo/og.js";
|
|
2
|
+
import { renderBrandOgImage } from "../brands/og.js";
|
|
3
|
+
export function renderFranchiseOg(doc, opts) {
|
|
4
|
+
const name = doc?.name ?? "Franchise";
|
|
5
|
+
return renderBrandOgImage({
|
|
6
|
+
name,
|
|
7
|
+
description: doc?.description?.slice(0, 120) ??
|
|
8
|
+
`Shop ${name} collectibles on ${opts.siteName}.`,
|
|
9
|
+
logoUrl: resolveOgImageUrl(doc?.display?.coverImage ?? null, opts.baseUrl),
|
|
10
|
+
}, opts.siteName);
|
|
11
|
+
}
|
|
@@ -45,6 +45,15 @@ export interface PublicProductListInput {
|
|
|
45
45
|
q?: string;
|
|
46
46
|
category?: string;
|
|
47
47
|
brand?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The IP the product is from, matched on `franchiseSlug`.
|
|
50
|
+
*
|
|
51
|
+
* 🛑 On the SLUG, not the display name — unlike `brand` directly above,
|
|
52
|
+
* which filters on `PRODUCT_FIELDS.BRAND` (the display name) and therefore
|
|
53
|
+
* orphans a brand's whole catalogue the moment anyone renames it. Franchise
|
|
54
|
+
* is new, so it gets the stable key rather than inheriting that trap.
|
|
55
|
+
*/
|
|
56
|
+
franchiseSlug?: string;
|
|
48
57
|
/** Pipe-joined multi-select (`new|used`) — emitted as an OR-group, never as AND. */
|
|
49
58
|
condition?: string;
|
|
50
59
|
storeId?: string;
|
|
@@ -267,6 +267,9 @@ function buildFirestoreSafeFilters(input, opts) {
|
|
|
267
267
|
}
|
|
268
268
|
if (input.brand)
|
|
269
269
|
parts.push(sieveFilter(PRODUCT_FIELDS.BRAND, SIEVE_OP.EQ, input.brand));
|
|
270
|
+
if (input.franchiseSlug) {
|
|
271
|
+
parts.push(sieveFilter(PRODUCT_FIELDS.FRANCHISE_SLUG, SIEVE_OP.EQ, input.franchiseSlug));
|
|
272
|
+
}
|
|
270
273
|
if (input.condition) {
|
|
271
274
|
// Pipe-joined OR-group, NOT `sieveMultiEq` — that emits
|
|
272
275
|
// `condition==new,condition==used`, an AND of two equalities on one field,
|
|
@@ -42,6 +42,8 @@ export declare const PRODUCT_FIELDS: {
|
|
|
42
42
|
readonly SUBCATEGORY: "subcategory";
|
|
43
43
|
readonly BRAND: "brand";
|
|
44
44
|
readonly BRAND_SLUG: "brandSlug";
|
|
45
|
+
readonly FRANCHISE: "franchise";
|
|
46
|
+
readonly FRANCHISE_SLUG: "franchiseSlug";
|
|
45
47
|
readonly PRICE: "price";
|
|
46
48
|
readonly CURRENCY: "currency";
|
|
47
49
|
readonly STOCK_QUANTITY: "stockQuantity";
|
|
@@ -529,6 +531,7 @@ export declare const CATEGORY_FIELDS: {
|
|
|
529
531
|
readonly BRAND: "brand";
|
|
530
532
|
readonly SUBLISTING: "sublisting";
|
|
531
533
|
readonly BUNDLE: "bundle";
|
|
534
|
+
readonly FRANCHISE: "franchise";
|
|
532
535
|
};
|
|
533
536
|
readonly BUNDLE_STOCK_STATUS_VALUES: {
|
|
534
537
|
readonly AVAILABLE: "available";
|
|
@@ -49,6 +49,10 @@ export const PRODUCT_FIELDS = {
|
|
|
49
49
|
SUBCATEGORY: "subcategory",
|
|
50
50
|
BRAND: "brand",
|
|
51
51
|
BRAND_SLUG: "brandSlug",
|
|
52
|
+
/* The IP a product is from. Matched on the SLUG, never the display name —
|
|
53
|
+
* see the note on `ProductDocument.franchiseSlug`. */
|
|
54
|
+
FRANCHISE: "franchise",
|
|
55
|
+
FRANCHISE_SLUG: "franchiseSlug",
|
|
52
56
|
PRICE: "price",
|
|
53
57
|
CURRENCY: "currency",
|
|
54
58
|
STOCK_QUANTITY: "stockQuantity",
|
|
@@ -604,6 +608,13 @@ export const CATEGORY_FIELDS = {
|
|
|
604
608
|
BRAND: "brand",
|
|
605
609
|
SUBLISTING: "sublisting",
|
|
606
610
|
BUNDLE: "bundle",
|
|
611
|
+
/*
|
|
612
|
+
* The IP a product belongs to — Beyblade, Pokémon, Marvel. A sibling of
|
|
613
|
+
* BRAND, not of CATEGORY: both are tier-0 rows outside the forest's DFS
|
|
614
|
+
* numbering that a product points AT, rather than nodes it lives under.
|
|
615
|
+
* A brand is who MADE it (Takara-Tomy); a franchise is what it IS FROM.
|
|
616
|
+
*/
|
|
617
|
+
FRANCHISE: "franchise",
|
|
607
618
|
},
|
|
608
619
|
BUNDLE_STOCK_STATUS_VALUES: {
|
|
609
620
|
AVAILABLE: "available",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CategoryDocument } from "../schemas";
|
|
2
|
+
/**
|
|
3
|
+
* A FRANCHISE page — every listing from one IP.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 Products are matched on `franchiseSlug`, NOT on the display name. That is
|
|
6
|
+
* the one place this deliberately diverges from `BrandDetailPageView`, which
|
|
7
|
+
* filters `sieveFilter("brand", EQ, brandName)` and therefore orphans a brand's
|
|
8
|
+
* entire catalogue the moment anyone renames it (`_helpers/brand-rows.ts`,
|
|
9
|
+
* trap 1). Franchise is new, so it starts with the stable key.
|
|
10
|
+
*
|
|
11
|
+
* 🛑 An INACTIVE franchise still renders. Seven of the eight seeded rows have
|
|
12
|
+
* no stock and are inactive precisely so they stay out of the grid — but their
|
|
13
|
+
* pages must resolve, because `CharacterHotspot` ships eighteen `/franchise/*`
|
|
14
|
+
* links that were 404ing. An inactive row renders an honest "nothing listed
|
|
15
|
+
* yet" rather than a dead end or a lie.
|
|
16
|
+
*
|
|
17
|
+
* @tag domain:categories
|
|
18
|
+
* @tag layer:view
|
|
19
|
+
* @tag access:server-only
|
|
20
|
+
*/
|
|
21
|
+
export interface FranchiseDetailPageViewProps {
|
|
22
|
+
slug: string;
|
|
23
|
+
franchise: CategoryDocument;
|
|
24
|
+
}
|
|
25
|
+
export declare function FranchiseDetailPageView({ slug, franchise, }: FranchiseDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { listPublicProducts } from "../../../_internal/server/features/products/list-public.js";
|
|
3
|
+
import { toProductItem } from "../../../_internal/shared/features/products/to-product-item.js";
|
|
4
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
5
|
+
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
|
|
6
|
+
import { Container, Row, Stack } from "../../../ui/components/Layout.js";
|
|
7
|
+
import { Main, Section } from "../../../ui/components/Semantic.js";
|
|
8
|
+
import { Heading, Text } from "../../../ui/components/Typography.js";
|
|
9
|
+
import { MediaImage } from "../../media/MediaImage.js";
|
|
10
|
+
import { ProductGrid } from "../../products/components/ProductGrid.js";
|
|
11
|
+
import { Alert } from "../../../ui/components/Alert.js";
|
|
12
|
+
const PAGE_SIZE = 24;
|
|
13
|
+
export async function FranchiseDetailPageView({ slug, franchise, }) {
|
|
14
|
+
const result = await safeRead(() => listPublicProducts({
|
|
15
|
+
franchiseSlug: slug,
|
|
16
|
+
page: 1,
|
|
17
|
+
pageSize: PAGE_SIZE,
|
|
18
|
+
}), { route: "/franchise/[slug]", key: "franchise.products", fallback: null });
|
|
19
|
+
/*
|
|
20
|
+
* Every scalar read off `result` happens HERE, before the narrowing below —
|
|
21
|
+
* `audit-projection-shadow` is right that reading the raw result after
|
|
22
|
+
* taking a projection of it is the shape that lets a repository document
|
|
23
|
+
* leak onward (Root Cause #70). These three are the only things the view
|
|
24
|
+
* needs from it.
|
|
25
|
+
*/
|
|
26
|
+
const readFailed = result === null;
|
|
27
|
+
const truncated = result?.truncated === true;
|
|
28
|
+
const reportedTotal = result?.total;
|
|
29
|
+
/*
|
|
30
|
+
* `hidePublicTestData` widens to its own `TestDataFlagged` shape, which has
|
|
31
|
+
* no index signature and so does not satisfy `toProductItem`'s
|
|
32
|
+
* `FirestoreDocument` parameter. The cast is at the adapter boundary and
|
|
33
|
+
* restores the type the repository actually returned — not a claim about
|
|
34
|
+
* unknown data.
|
|
35
|
+
*/
|
|
36
|
+
const rows = hidePublicTestData(result?.items ?? []);
|
|
37
|
+
const products = rows.map((r) => toProductItem(r));
|
|
38
|
+
const total = reportedTotal ?? products.length;
|
|
39
|
+
const cover = franchise.display?.coverImage;
|
|
40
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsxs(Row, { align: "center", gap: "comfortable", className: "mb-8", children: [cover ? (_jsx(MediaImage, { src: cover, alt: franchise.name ?? slug, size: "thumbnail", rounded: "lg" })) : null, _jsxs(Stack, { gap: "dense", children: [_jsx(Heading, { level: 1, color: "primary", size: "3xl", weight: "semibold", children: franchise.name }), franchise.description ? (_jsx(Text, { color: "muted", className: "max-w-2xl", children: franchise.description })) : null] })] }), franchise.isActive === false ? (_jsxs(Alert, { variant: "info", className: "mb-6", children: ["We are not stocking ", franchise.name, " yet. This page is here so the link works \u2014 check back, or browse what we do carry."] })) : null, readFailed ? (_jsxs(Alert, { variant: "warning", children: ["We could not load ", franchise.name, " listings just now. Please try again."] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: "muted", className: "mb-4", children: total === 0
|
|
41
|
+
? "No listings yet."
|
|
42
|
+
: `${total}${truncated ? "+" : ""} listing${total === 1 ? "" : "s"}` }), _jsx(ProductGrid, { products: products, emptyLabel: "No listings yet." })] }))] }) }) }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FRANCHISE index — the IPs we carry.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 ACTIVE ONLY, and that is the dead-facet guard. `brand-rows.ts` trap 3:
|
|
5
|
+
* "a row with no product is a DEAD FACET, and worse than absent — it renders
|
|
6
|
+
* as a card in the grid that opens an empty page." Seven of the eight seeded
|
|
7
|
+
* franchises have no stock yet; they stay out of here until one flag flips,
|
|
8
|
+
* while their detail pages still resolve for the links that point at them.
|
|
9
|
+
*
|
|
10
|
+
* @tag domain:categories
|
|
11
|
+
* @tag layer:view
|
|
12
|
+
* @tag access:server-only
|
|
13
|
+
*/
|
|
14
|
+
export declare function FranchisesIndexView(): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { listActiveFranchises } from "../../../_internal/server/features/franchises/data.js";
|
|
3
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
4
|
+
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
|
|
5
|
+
import { Container, Grid } from "../../../ui/components/Layout.js";
|
|
6
|
+
import { Main, Section } from "../../../ui/components/Semantic.js";
|
|
7
|
+
import { Heading, Text } from "../../../ui/components/Typography.js";
|
|
8
|
+
import { Div } from "../../../ui/components/Div.js";
|
|
9
|
+
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
10
|
+
import { MediaImage } from "../../media/MediaImage.js";
|
|
11
|
+
import { Alert } from "../../../ui/components/Alert.js";
|
|
12
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
13
|
+
/**
|
|
14
|
+
* The FRANCHISE index — the IPs we carry.
|
|
15
|
+
*
|
|
16
|
+
* 🛑 ACTIVE ONLY, and that is the dead-facet guard. `brand-rows.ts` trap 3:
|
|
17
|
+
* "a row with no product is a DEAD FACET, and worse than absent — it renders
|
|
18
|
+
* as a card in the grid that opens an empty page." Seven of the eight seeded
|
|
19
|
+
* franchises have no stock yet; they stay out of here until one flag flips,
|
|
20
|
+
* while their detail pages still resolve for the links that point at them.
|
|
21
|
+
*
|
|
22
|
+
* @tag domain:categories
|
|
23
|
+
* @tag layer:view
|
|
24
|
+
* @tag access:server-only
|
|
25
|
+
*/
|
|
26
|
+
export async function FranchisesIndexView() {
|
|
27
|
+
const franchises = await safeRead(() => listActiveFranchises().then(hidePublicTestData), {
|
|
28
|
+
route: "/franchise",
|
|
29
|
+
key: "franchises.list",
|
|
30
|
+
fallback: null,
|
|
31
|
+
});
|
|
32
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-2", color: "primary", size: "3xl", weight: "semibold", children: "Franchises" }), _jsx(Text, { color: "muted", className: "mb-8", children: "The universes we stock. A franchise is what a collectible is from \u2014 a brand is who made it." }), franchises === null ? (_jsx(Alert, { variant: "warning", children: "We could not load the franchise list just now. Please try again." })) : franchises.length === 0 ? (_jsx(Alert, { variant: "info", children: "No franchises are live yet." })) : (_jsx(Grid, { cols: "cards", gap: "comfortable", children: franchises.map((f) => (_jsx(TextLink, { href: ROUTES.PUBLIC.FRANCHISE_DETAIL(String(f.slug ?? f.id)), children: _jsxs(Div, { surface: "card", padding: "card", rounded: "lg", border: "default", children: [f.display?.coverImage ? (_jsx(MediaImage, { src: f.display.coverImage, alt: f.name ?? "", size: "card", rounded: "md", className: "mb-3" })) : null, _jsx(Heading, { level: 2, size: "lg", weight: "semibold", children: f.name }), f.description ? (_jsx(Text, { color: "muted", size: "sm", className: "mt-1", truncate: 2, children: f.description })) : null] }) }, f.id))) }))] }) }) }));
|
|
33
|
+
}
|
|
@@ -303,6 +303,21 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
303
303
|
* Replaces the old `brandsRepository.findActive()` call site.
|
|
304
304
|
*/
|
|
305
305
|
findActiveBrands(): Promise<CategoryDocument[]>;
|
|
306
|
+
/**
|
|
307
|
+
* The FRANCHISE rows a visitor may browse — what a product is FROM, where a
|
|
308
|
+
* brand is who made it.
|
|
309
|
+
*
|
|
310
|
+
* 🛑 `isActive == true` is the whole guard against a dead facet. Seven of the
|
|
311
|
+
* eight seeded franchises have no stock yet and are seeded INACTIVE on
|
|
312
|
+
* purpose: their pages resolve (so `/franchise/marvel` is not a 404) but they
|
|
313
|
+
* are kept out of the grid, because a card that opens an empty shop is worse
|
|
314
|
+
* than no card — the rule `_helpers/brand-rows.ts` states as trap 3.
|
|
315
|
+
*
|
|
316
|
+
* Same query shape as `findActiveBrands`, so it rides the SAME
|
|
317
|
+
* (categoryType, isActive, order) composite index rather than needing a new
|
|
318
|
+
* one.
|
|
319
|
+
*/
|
|
320
|
+
findActiveFranchises(): Promise<CategoryDocument[]>;
|
|
306
321
|
/**
|
|
307
322
|
* SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
|
|
308
323
|
* human-entered category name.
|
|
@@ -1022,6 +1022,29 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
1022
1022
|
.get();
|
|
1023
1023
|
return snap.docs.map((d) => this.mapDoc(d));
|
|
1024
1024
|
}
|
|
1025
|
+
/**
|
|
1026
|
+
* The FRANCHISE rows a visitor may browse — what a product is FROM, where a
|
|
1027
|
+
* brand is who made it.
|
|
1028
|
+
*
|
|
1029
|
+
* 🛑 `isActive == true` is the whole guard against a dead facet. Seven of the
|
|
1030
|
+
* eight seeded franchises have no stock yet and are seeded INACTIVE on
|
|
1031
|
+
* purpose: their pages resolve (so `/franchise/marvel` is not a 404) but they
|
|
1032
|
+
* are kept out of the grid, because a card that opens an empty shop is worse
|
|
1033
|
+
* than no card — the rule `_helpers/brand-rows.ts` states as trap 3.
|
|
1034
|
+
*
|
|
1035
|
+
* Same query shape as `findActiveBrands`, so it rides the SAME
|
|
1036
|
+
* (categoryType, isActive, order) composite index rather than needing a new
|
|
1037
|
+
* one.
|
|
1038
|
+
*/
|
|
1039
|
+
async findActiveFranchises() {
|
|
1040
|
+
const snap = await this.db
|
|
1041
|
+
.collection(this.collection)
|
|
1042
|
+
.where(CATEGORY_FIELDS.CATEGORY_TYPE, "==", "franchise")
|
|
1043
|
+
.where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
|
|
1044
|
+
.orderBy(CATEGORY_FIELDS.ORDER, "asc")
|
|
1045
|
+
.get();
|
|
1046
|
+
return snap.docs.map((d) => this.mapDoc(d));
|
|
1047
|
+
}
|
|
1025
1048
|
/*
|
|
1026
1049
|
* 🛑 `incrementViewCount` DELETED 2026-10-10 — zero callers since
|
|
1027
1050
|
* 2026-08-31, when it was removed from the category-detail render path (a
|
|
@@ -33,9 +33,9 @@ export declare const collectionItemSchema: z.ZodObject<{
|
|
|
33
33
|
brandSlug?: string | undefined;
|
|
34
34
|
productCount?: number | undefined;
|
|
35
35
|
description?: string | undefined;
|
|
36
|
+
franchiseSlug?: string | undefined;
|
|
36
37
|
subtitle?: string | undefined;
|
|
37
38
|
bannerImage?: string | undefined;
|
|
38
|
-
franchiseSlug?: string | undefined;
|
|
39
39
|
manualProductIds?: string[] | undefined;
|
|
40
40
|
}, {
|
|
41
41
|
title: string;
|
|
@@ -46,10 +46,10 @@ export declare const collectionItemSchema: z.ZodObject<{
|
|
|
46
46
|
productCount?: number | undefined;
|
|
47
47
|
active?: boolean | undefined;
|
|
48
48
|
description?: string | undefined;
|
|
49
|
+
franchiseSlug?: string | undefined;
|
|
49
50
|
subtitle?: string | undefined;
|
|
50
51
|
sortOrder?: number | undefined;
|
|
51
52
|
bannerImage?: string | undefined;
|
|
52
|
-
franchiseSlug?: string | undefined;
|
|
53
53
|
manualProductIds?: string[] | undefined;
|
|
54
54
|
}>;
|
|
55
55
|
export declare const collectionListItemSchema: z.ZodObject<{
|
|
@@ -66,8 +66,8 @@ export declare const preorderItemSchema: z.ZodObject<{
|
|
|
66
66
|
youtubeId?: string | undefined;
|
|
67
67
|
duration?: number | undefined;
|
|
68
68
|
}[] | undefined;
|
|
69
|
-
images?: string[] | undefined;
|
|
70
69
|
franchise?: string | undefined;
|
|
70
|
+
images?: string[] | undefined;
|
|
71
71
|
preorderShipDate?: string | undefined;
|
|
72
72
|
}, {
|
|
73
73
|
id: string;
|
|
@@ -87,8 +87,8 @@ export declare const preorderItemSchema: z.ZodObject<{
|
|
|
87
87
|
youtubeId?: string | undefined;
|
|
88
88
|
duration?: number | undefined;
|
|
89
89
|
}[] | undefined;
|
|
90
|
+
franchise?: string | undefined;
|
|
90
91
|
images?: string[] | undefined;
|
|
91
92
|
isFeatured?: boolean | undefined;
|
|
92
|
-
franchise?: string | undefined;
|
|
93
93
|
preorderShipDate?: string | undefined;
|
|
94
94
|
}>;
|
|
@@ -398,6 +398,10 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
|
|
|
398
398
|
canFilter: boolean;
|
|
399
399
|
canSort: boolean;
|
|
400
400
|
};
|
|
401
|
+
franchiseSlug: {
|
|
402
|
+
canFilter: boolean;
|
|
403
|
+
canSort: boolean;
|
|
404
|
+
};
|
|
401
405
|
sublistingCategoryId: {
|
|
402
406
|
canFilter: boolean;
|
|
403
407
|
canSort: boolean;
|
|
@@ -1519,6 +1519,11 @@ ProductRepository.SIEVE_FIELDS = {
|
|
|
1519
1519
|
"physicalLocation.shelf": { canFilter: true, canSort: false },
|
|
1520
1520
|
"physicalLocation.bin": { canFilter: true, canSort: false },
|
|
1521
1521
|
brandSlug: { canFilter: true, canSort: false },
|
|
1522
|
+
/* 🛑 Must be declared here or the clause is SILENTLY DROPPED — sieve.ts
|
|
1523
|
+
* sets `throwExceptions: false`, so an unallowlisted field does not raise,
|
|
1524
|
+
* it just vanishes and the franchise page returns the whole catalogue
|
|
1525
|
+
* (Root Cause #62). */
|
|
1526
|
+
franchiseSlug: { canFilter: true, canSort: false },
|
|
1522
1527
|
sublistingCategoryId: { canFilter: true, canSort: false },
|
|
1523
1528
|
createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
1524
1529
|
updatedAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
@@ -216,6 +216,19 @@ export interface ProductDocument extends BaseDocument {
|
|
|
216
216
|
subcategory?: string;
|
|
217
217
|
brand?: string;
|
|
218
218
|
brandSlug?: string;
|
|
219
|
+
/**
|
|
220
|
+
* The IP this product is FROM — "Beyblade", "Pokémon". A sibling of `brand`
|
|
221
|
+
* (who MADE it), matching a `categoryType: "franchise"` row.
|
|
222
|
+
*
|
|
223
|
+
* 🛑 `franchiseSlug` is the match key here, NOT the display name — the
|
|
224
|
+
* opposite of `brand`, which `BrandDetailPageView` filters on by display name
|
|
225
|
+
* and which therefore orphans its whole catalogue on a rename (see
|
|
226
|
+
* `_helpers/brand-rows.ts`, trap 1). Franchise is new, so it gets the
|
|
227
|
+
* correct shape from the start rather than inheriting that trap; `franchise`
|
|
228
|
+
* is the denormalised label for display only.
|
|
229
|
+
*/
|
|
230
|
+
franchise?: string;
|
|
231
|
+
franchiseSlug?: string;
|
|
219
232
|
price: number;
|
|
220
233
|
/**
|
|
221
234
|
* The strike-through "was ₹X", for a listing the seller is discounting.
|
package/dist/index.d.ts
CHANGED
|
@@ -651,6 +651,7 @@ export { historySeedData } from "./seed/index";
|
|
|
651
651
|
export { groupedListingsSeedData } from "./seed/index";
|
|
652
652
|
export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT } from "./seed/index";
|
|
653
653
|
export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./seed/index";
|
|
654
|
+
export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./seed/index";
|
|
654
655
|
export type { CouponDiscountType } from "./seed/index";
|
|
655
656
|
export type { FirestoreIndexConfig } from "./seed/index";
|
|
656
657
|
export type { HomepageSectionType } from "./seed/index";
|
package/dist/index.js
CHANGED
|
@@ -1375,6 +1375,9 @@ export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT } from "./seed/index.js";
|
|
|
1375
1375
|
// Same Root Cause #18 shape as mediaAssetsSeedData above: seed-cli.mjs imports
|
|
1376
1376
|
// the bare "@mohasinac/appkit" package, which resolves through THIS file.
|
|
1377
1377
|
export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./seed/index.js";
|
|
1378
|
+
// Franchise — the IP a product is FROM (brand is who MADE it). Re-exported
|
|
1379
|
+
// from the outer barrel because seed-cli.mjs imports the bare package.
|
|
1380
|
+
export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./seed/index.js";
|
|
1378
1381
|
export { NOTIFICATION_TYPE_VALUES, NOTIFICATION_RELATED_TYPE_VALUES } from "./features/admin/schemas/firestore.js";
|
|
1379
1382
|
// [UTIL]-Pure utility â€" no framework or runtime dependency; safe to import from any environment.
|
|
1380
1383
|
// ErrorCategory - Shared export for error category.
|
|
@@ -54,6 +54,8 @@ export declare const DEFAULT_ROUTE_MAP: {
|
|
|
54
54
|
readonly CATEGORY_DETAIL: (slug: string) => string;
|
|
55
55
|
readonly BRANDS: "/brands";
|
|
56
56
|
readonly BRAND_DETAIL: (slug: string) => string;
|
|
57
|
+
readonly FRANCHISE: "/franchise";
|
|
58
|
+
readonly FRANCHISE_DETAIL: (slug: string) => string;
|
|
57
59
|
readonly REVIEWS: "/reviews";
|
|
58
60
|
readonly REVIEW_DETAIL: (id: string) => string;
|
|
59
61
|
readonly SEARCH: "/search";
|
|
@@ -468,6 +470,8 @@ export declare const ROUTES: {
|
|
|
468
470
|
readonly CATEGORY_DETAIL: (slug: string) => string;
|
|
469
471
|
readonly BRANDS: "/brands";
|
|
470
472
|
readonly BRAND_DETAIL: (slug: string) => string;
|
|
473
|
+
readonly FRANCHISE: "/franchise";
|
|
474
|
+
readonly FRANCHISE_DETAIL: (slug: string) => string;
|
|
471
475
|
readonly REVIEWS: "/reviews";
|
|
472
476
|
readonly REVIEW_DETAIL: (id: string) => string;
|
|
473
477
|
readonly SEARCH: "/search";
|
|
@@ -47,6 +47,15 @@ export const DEFAULT_ROUTE_MAP = {
|
|
|
47
47
|
CATEGORY_DETAIL: (slug) => `/categories/${slug}`,
|
|
48
48
|
BRANDS: "/brands",
|
|
49
49
|
BRAND_DETAIL: (slug) => `/brands/${slug}`,
|
|
50
|
+
/*
|
|
51
|
+
* FRANCHISE — what a collectible is FROM, where a brand is who MADE it.
|
|
52
|
+
* Singular in the path because `CharacterHotspot` already ships eighteen
|
|
53
|
+
* `/franchise/<slug>` links; matching them is what turns those 404s into
|
|
54
|
+
* real pages, and changing the segment to `/franchises` would just move
|
|
55
|
+
* the breakage.
|
|
56
|
+
*/
|
|
57
|
+
FRANCHISE: "/franchise",
|
|
58
|
+
FRANCHISE_DETAIL: (slug) => `/franchise/${slug}`,
|
|
50
59
|
REVIEWS: "/reviews",
|
|
51
60
|
REVIEW_DETAIL: (id) => `/reviews/${id}`,
|
|
52
61
|
SEARCH: "/search",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CategoryDocument } from "../../features/categories/schemas";
|
|
2
|
+
export interface FranchiseSeedInput {
|
|
3
|
+
/** Without the `franchise-` prefix. Products match on this, never the name. */
|
|
4
|
+
slug: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
/** Live only when real stock exists — see the dead-facet rule above. */
|
|
8
|
+
active: boolean;
|
|
9
|
+
featured?: boolean;
|
|
10
|
+
keywords: string[];
|
|
11
|
+
seoDescription: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function franchiseRow(input: FranchiseSeedInput, order: number, stamps: {
|
|
14
|
+
createdBy: string;
|
|
15
|
+
createdAt: Date;
|
|
16
|
+
updatedAt: Date;
|
|
17
|
+
}): Partial<CategoryDocument>;
|
|
18
|
+
export declare function buildFranchiseRows(stamps: {
|
|
19
|
+
createdBy: string;
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
updatedAt: Date;
|
|
22
|
+
}): Partial<CategoryDocument>[];
|
|
23
|
+
export declare const FRANCHISE_ROW_COUNT: number;
|
|
24
|
+
/** The slugs a product's `franchiseSlug` may point at. */
|
|
25
|
+
export declare const FRANCHISE_SLUGS: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Category ROOT -> the franchise everything beneath it belongs to.
|
|
28
|
+
*
|
|
29
|
+
* Derived rather than stamped per product: a product already carries its full
|
|
30
|
+
* ancestor chain, so the root is free and cannot drift from the taxonomy the
|
|
31
|
+
* way a hand-written field would (Root Cause #42).
|
|
32
|
+
*
|
|
33
|
+
* 🛑 Deliberately PARTIAL. `category-living-collectibles` is absent because a
|
|
34
|
+
* juniper bonsai and a leucistic axolotl are not from an IP — they are living
|
|
35
|
+
* things. Mapping every root for completeness' sake would assert a franchise
|
|
36
|
+
* that does not exist, and "no franchise" is the correct answer for 4 of the
|
|
37
|
+
* 1,308 seeded products. A root with no entry yields no franchise, not a
|
|
38
|
+
* fallback.
|
|
39
|
+
*/
|
|
40
|
+
export declare const FRANCHISE_BY_CATEGORY_ROOT: Readonly<Record<string, {
|
|
41
|
+
slug: string;
|
|
42
|
+
name: string;
|
|
43
|
+
}>>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: a product's BRAND is who made it (Takara-Tomy); its FRANCHISE is what
|
|
3
|
+
* it is from (Beyblade). The site had no way to express the second, while
|
|
4
|
+
* `CharacterHotspot` shipped eighteen `/franchise/*` links to a route that
|
|
5
|
+
* did not exist. This is that unit, modelled on brands per the explicit
|
|
6
|
+
* instruction — "like brands make franchise its own separate unit".
|
|
7
|
+
*
|
|
8
|
+
* WHAT: one factory plus eight declarations.
|
|
9
|
+
*
|
|
10
|
+
* EXPORTS:
|
|
11
|
+
* FRANCHISE_ROWS — Partial<CategoryDocument>[], categoryType:"franchise"
|
|
12
|
+
* FRANCHISE_ROW_COUNT — asserted by the seed so a silent drop is loud
|
|
13
|
+
*
|
|
14
|
+
* ── 🛑 THE DEAD-FACET RULE, AND HOW THESE OBEY IT ─────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* `brand-rows.ts` trap 3 states it exactly: *"A brand row with no product is a
|
|
17
|
+
* DEAD FACET, and worse than absent — it renders as a card in the grid that
|
|
18
|
+
* opens an empty page."* Seven of the eight rows below have no stock today,
|
|
19
|
+
* because this catalogue sells Beyblade.
|
|
20
|
+
*
|
|
21
|
+
* So they are seeded **`isActive: false`**. That is not a soft delete — the row
|
|
22
|
+
* exists, it is editable in the admin category editor, its page resolves, and
|
|
23
|
+
* `/franchise/marvel` stops being a 404. It is excluded from the grid and from
|
|
24
|
+
* search until it has something to sell, at which point one flag flips.
|
|
25
|
+
*
|
|
26
|
+
* The alternative readings were both worse: omitting them leaves eighteen links
|
|
27
|
+
* broken, and seeding them active puts seven empty shopfronts on a live grid.
|
|
28
|
+
*
|
|
29
|
+
* 🛑 `isActive` is therefore load-bearing here, not boilerplate. A future
|
|
30
|
+
* franchise row should be `false` until a product carries its `franchiseSlug`.
|
|
31
|
+
*
|
|
32
|
+
* ── Why tier-0 and outside the forest ─────────────────────────────────────
|
|
33
|
+
*
|
|
34
|
+
* Same as brands: `position` is NOT set, because these live outside the
|
|
35
|
+
* category forest's DFS numbering and a position inside it would corrupt the
|
|
36
|
+
* ranges `onCategoryWrite` shifts on insert.
|
|
37
|
+
*
|
|
38
|
+
* @tag domain:categories
|
|
39
|
+
* @tag layer:seed
|
|
40
|
+
* @tag pattern:shared-literal
|
|
41
|
+
* @tag access:server-only
|
|
42
|
+
* @tag consumers:seed/categories-seed-data.ts
|
|
43
|
+
* @tag sideEffects:none
|
|
44
|
+
*/
|
|
45
|
+
/* 🛑 `constants/field-names`, NOT the schema copy — there are two
|
|
46
|
+
* `CATEGORY_FIELDS` and only this one carries `CATEGORY_TYPE_VALUES`.
|
|
47
|
+
* `categories-seed-data.ts` imports this one; matching it is what keeps the
|
|
48
|
+
* discriminator a single spelling. Same reasoning as `brand-rows.ts`. */
|
|
49
|
+
import { CATEGORY_FIELDS } from "../../constants/field-names.js";
|
|
50
|
+
import { seedPhoto } from "./media.js";
|
|
51
|
+
export function franchiseRow(input, order, stamps) {
|
|
52
|
+
const id = `franchise-${input.slug}`;
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
slug: id,
|
|
56
|
+
name: input.name,
|
|
57
|
+
categoryType: CATEGORY_FIELDS.CATEGORY_TYPE_VALUES.FRANCHISE,
|
|
58
|
+
description: input.description,
|
|
59
|
+
rootId: id,
|
|
60
|
+
parentIds: [],
|
|
61
|
+
childrenIds: [],
|
|
62
|
+
ancestors: [],
|
|
63
|
+
tier: 0,
|
|
64
|
+
path: id,
|
|
65
|
+
isLeaf: true,
|
|
66
|
+
order,
|
|
67
|
+
/* `icon` deliberately unset — the brands grid reads `display.icon` first
|
|
68
|
+
* and an emoji there would beat a real cover image (brand-rows trap 2). */
|
|
69
|
+
display: {
|
|
70
|
+
coverImage: seedPhoto(`franchise-cover-${input.slug}-20261014`, 800, 800),
|
|
71
|
+
showInMenu: false,
|
|
72
|
+
showInFooter: false,
|
|
73
|
+
},
|
|
74
|
+
isFeatured: input.featured ?? false,
|
|
75
|
+
isActive: input.active,
|
|
76
|
+
isSearchable: input.active,
|
|
77
|
+
createdBy: stamps.createdBy,
|
|
78
|
+
createdAt: stamps.createdAt,
|
|
79
|
+
updatedAt: stamps.updatedAt,
|
|
80
|
+
seo: {
|
|
81
|
+
title: `${input.name} | LetItRip`,
|
|
82
|
+
description: input.seoDescription,
|
|
83
|
+
keywords: input.keywords,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
* The eight. Beyblade is the catalogue; the other seven are the IPs
|
|
89
|
+
* `CharacterHotspot` already links to, seeded inactive so those links resolve
|
|
90
|
+
* without advertising an empty shop.
|
|
91
|
+
*/
|
|
92
|
+
const INPUTS = [
|
|
93
|
+
{
|
|
94
|
+
slug: "beyblade",
|
|
95
|
+
name: "Beyblade",
|
|
96
|
+
description: "The spinning-top battling franchise — every generation from the original Spin Gear tops through Metal Fight, Burst and Beyblade X. Complete tops, loose parts, stadiums and accessories.",
|
|
97
|
+
active: true,
|
|
98
|
+
featured: true,
|
|
99
|
+
keywords: ["beyblade", "spinning tops", "burst", "metal fight", "beyblade x"],
|
|
100
|
+
seoDescription: "Shop Beyblade across every generation — tops, parts, stadiums and spares, new and pre-owned.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
slug: "marvel",
|
|
104
|
+
name: "Marvel",
|
|
105
|
+
description: "Figures, collectibles and merchandise from the Marvel universe.",
|
|
106
|
+
active: false,
|
|
107
|
+
keywords: ["marvel", "comics", "superhero", "figures"],
|
|
108
|
+
seoDescription: "Marvel collectibles and figures.",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
slug: "dc-comics",
|
|
112
|
+
name: "DC Comics",
|
|
113
|
+
description: "Figures, collectibles and merchandise from the DC universe.",
|
|
114
|
+
active: false,
|
|
115
|
+
keywords: ["dc", "dc comics", "superhero", "figures"],
|
|
116
|
+
seoDescription: "DC Comics collectibles and figures.",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
slug: "dragon-ball",
|
|
120
|
+
name: "Dragon Ball",
|
|
121
|
+
description: "Figures and collectibles from the Dragon Ball series.",
|
|
122
|
+
active: false,
|
|
123
|
+
keywords: ["dragon ball", "anime", "figures", "shonen"],
|
|
124
|
+
seoDescription: "Dragon Ball collectibles and figures.",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
slug: "naruto",
|
|
128
|
+
name: "Naruto",
|
|
129
|
+
description: "Figures and collectibles from the Naruto series.",
|
|
130
|
+
active: false,
|
|
131
|
+
keywords: ["naruto", "anime", "figures", "shonen"],
|
|
132
|
+
seoDescription: "Naruto collectibles and figures.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
slug: "bleach",
|
|
136
|
+
name: "Bleach",
|
|
137
|
+
description: "Figures and collectibles from the Bleach series.",
|
|
138
|
+
active: false,
|
|
139
|
+
keywords: ["bleach", "anime", "figures", "shonen"],
|
|
140
|
+
seoDescription: "Bleach collectibles and figures.",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
slug: "one-piece",
|
|
144
|
+
name: "One Piece",
|
|
145
|
+
description: "Figures and collectibles from the One Piece series.",
|
|
146
|
+
active: false,
|
|
147
|
+
keywords: ["one piece", "anime", "figures", "shonen"],
|
|
148
|
+
seoDescription: "One Piece collectibles and figures.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
slug: "one-punch-man",
|
|
152
|
+
name: "One Punch Man",
|
|
153
|
+
description: "Figures and collectibles from the One Punch Man series.",
|
|
154
|
+
active: false,
|
|
155
|
+
keywords: ["one punch man", "anime", "figures"],
|
|
156
|
+
seoDescription: "One Punch Man collectibles and figures.",
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
export function buildFranchiseRows(stamps) {
|
|
160
|
+
return INPUTS.map((input, i) => franchiseRow(input, i + 1, stamps));
|
|
161
|
+
}
|
|
162
|
+
export const FRANCHISE_ROW_COUNT = INPUTS.length;
|
|
163
|
+
/** The slugs a product's `franchiseSlug` may point at. */
|
|
164
|
+
export const FRANCHISE_SLUGS = INPUTS.map((i) => `franchise-${i.slug}`);
|
|
165
|
+
/**
|
|
166
|
+
* Category ROOT -> the franchise everything beneath it belongs to.
|
|
167
|
+
*
|
|
168
|
+
* Derived rather than stamped per product: a product already carries its full
|
|
169
|
+
* ancestor chain, so the root is free and cannot drift from the taxonomy the
|
|
170
|
+
* way a hand-written field would (Root Cause #42).
|
|
171
|
+
*
|
|
172
|
+
* 🛑 Deliberately PARTIAL. `category-living-collectibles` is absent because a
|
|
173
|
+
* juniper bonsai and a leucistic axolotl are not from an IP — they are living
|
|
174
|
+
* things. Mapping every root for completeness' sake would assert a franchise
|
|
175
|
+
* that does not exist, and "no franchise" is the correct answer for 4 of the
|
|
176
|
+
* 1,308 seeded products. A root with no entry yields no franchise, not a
|
|
177
|
+
* fallback.
|
|
178
|
+
*/
|
|
179
|
+
export const FRANCHISE_BY_CATEGORY_ROOT = {
|
|
180
|
+
"category-spinning-tops": { slug: "franchise-beyblade", name: "Beyblade" },
|
|
181
|
+
};
|
|
@@ -56,6 +56,7 @@ import { buildCategoryTree } from "./_helpers/category-tree.js";
|
|
|
56
56
|
import { withCategorySearchTxt } from "./_helpers/search-txt-wrappers.js";
|
|
57
57
|
import { CATEGORY_FOREST } from "./_helpers/category-forest.js";
|
|
58
58
|
import { BRAND_ROW_COUNT, buildBrandRows } from "./_helpers/brand-rows.js";
|
|
59
|
+
import { FRANCHISE_ROW_COUNT, buildFranchiseRows } from "./_helpers/franchise-rows.js";
|
|
59
60
|
import { MODEL_LEAF_COUNT } from "./_helpers/category-models.js";
|
|
60
61
|
import { PART_LEAF_COUNT } from "./_helpers/category-parts.js";
|
|
61
62
|
import { MARKET_BAND_LEAF_IDS } from "./_helpers/category-content.js";
|
|
@@ -195,6 +196,14 @@ const brandRows = buildBrandRows({
|
|
|
195
196
|
createdAt: daysAgo(300),
|
|
196
197
|
updatedAt: daysAgo(30),
|
|
197
198
|
});
|
|
199
|
+
// FRANCHISE rows — what a product is FROM, where a brand is who MADE it.
|
|
200
|
+
// Same tier-0, outside-the-forest shape as brands. Seven of the eight are
|
|
201
|
+
// seeded INACTIVE on purpose; see the dead-facet rule in _helpers/franchise-rows.ts.
|
|
202
|
+
const franchiseRows = buildFranchiseRows({
|
|
203
|
+
createdBy: "user-admin-letitrip",
|
|
204
|
+
createdAt: daysAgo(300),
|
|
205
|
+
updatedAt: daysAgo(30),
|
|
206
|
+
});
|
|
198
207
|
// P-17 — 5 bundle rows (categoryType:"bundle") grouping the Beyblade-minimal
|
|
199
208
|
// standard products by generation, plus one cross-generation starter pack.
|
|
200
209
|
// Every bundle references real seeded product ids (products-standard-seed-data.ts),
|
|
@@ -504,6 +513,7 @@ const allCategoryRows = [
|
|
|
504
513
|
...rawCategories.map((c) => ({ ancestors: [], ...c, createdByType: "admin" })),
|
|
505
514
|
...sublistingRows.map((s) => ({ ancestors: [], ...s, createdByType: "admin" })),
|
|
506
515
|
...brandRows.map((b) => ({ ancestors: [], ...b, createdByType: "admin" })),
|
|
516
|
+
...franchiseRows.map((f) => ({ ancestors: [], ...f, createdByType: "admin" })),
|
|
507
517
|
...bundleRows.map((b) => ({ ancestors: [], ...b })),
|
|
508
518
|
]
|
|
509
519
|
.map(withGeneratedContent)
|
|
@@ -610,6 +620,9 @@ if (tier4Rows !== expectedTier4) {
|
|
|
610
620
|
if (brandRows.length !== BRAND_ROW_COUNT) {
|
|
611
621
|
throw new Error(`category seed: ${brandRows.length} brand rows built, expected ${BRAND_ROW_COUNT}`);
|
|
612
622
|
}
|
|
623
|
+
if (franchiseRows.length !== FRANCHISE_ROW_COUNT) {
|
|
624
|
+
throw new Error(`category seed: ${franchiseRows.length} franchise rows built, expected ${FRANCHISE_ROW_COUNT}`);
|
|
625
|
+
}
|
|
613
626
|
{
|
|
614
627
|
/*
|
|
615
628
|
* A price band keyed on a leaf that does not exist is dead weight that READS
|
package/dist/seed/index.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export { groupedListingsSeedData } from "./grouped-listings-seed-data";
|
|
|
79
79
|
export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT, } from "./media-assets-seed-data";
|
|
80
80
|
export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./products-catalogue-seed-data";
|
|
81
81
|
export { GENERATED_PART_COLOURS, GENERATED_PART_COLOUR_COUNT, } from "./_helpers/part-colours-generated";
|
|
82
|
+
export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./_helpers/franchise-rows";
|
|
82
83
|
export { scammersSeedData } from "./scammers-seed-data";
|
|
83
84
|
export { supportTicketsSeedData } from "./support-tickets-seed-data";
|
|
84
85
|
export { testerChecklistSeedData, testerResponsesSeedData, } from "../features/tester/seed-data";
|
package/dist/seed/index.js
CHANGED
|
@@ -78,6 +78,7 @@ export { groupedListingsSeedData } from "./grouped-listings-seed-data.js";
|
|
|
78
78
|
export { mediaAssetsSeedData, MEDIA_ASSET_SEED_COUNT, } from "./media-assets-seed-data.js";
|
|
79
79
|
export { productsCatalogueSeedData, CATALOGUE_LISTING_COUNT, } from "./products-catalogue-seed-data.js";
|
|
80
80
|
export { GENERATED_PART_COLOURS, GENERATED_PART_COLOUR_COUNT, } from "./_helpers/part-colours-generated.js";
|
|
81
|
+
export { FRANCHISE_ROW_COUNT, FRANCHISE_SLUGS, FRANCHISE_BY_CATEGORY_ROOT, } from "./_helpers/franchise-rows.js";
|
|
81
82
|
export { scammersSeedData } from "./scammers-seed-data.js";
|
|
82
83
|
export { supportTicketsSeedData } from "./support-tickets-seed-data.js";
|
|
83
84
|
// Tester checklist/response seed data lives in ../features/tester/seed-data (isolated from
|
package/dist/server.d.ts
CHANGED
|
@@ -509,6 +509,7 @@ export { getPreOrderForDetail, getProductFeaturesForPreOrder } from "./_internal
|
|
|
509
509
|
export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial, listStorePreOrdersInitial, listSitemapStores } from "./_internal/server/features/stores/index";
|
|
510
510
|
export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, type ToStoreDetailOptions, type ToStoreListItemOptions, type StoreProjectionSource, } from "./_internal/server/features/stores/adapters";
|
|
511
511
|
export { getBrandForDetail } from "./_internal/server/features/brands/index";
|
|
512
|
+
export { getFranchiseForDetail, listActiveFranchises, renderFranchiseOg, } from "./_internal/server/features/franchises/index";
|
|
512
513
|
export { getBlogPostForDetail } from "./_internal/server/features/blog/index";
|
|
513
514
|
export { getEventForDetail } from "./_internal/server/features/events/index";
|
|
514
515
|
export { getSublistingCategoryForDetail } from "./_internal/server/features/sublisting-categories/index";
|
package/dist/server.js
CHANGED
|
@@ -1387,6 +1387,7 @@ export { getStoreForDetail, listStoreProductsInitial, listStoreAuctionsInitial,
|
|
|
1387
1387
|
// may reach a client component or an HTTP response. See adapters.ts header.
|
|
1388
1388
|
export { toStoreDetail, toStoreListItem, PUBLIC_STORE_FIELDS, PRIVATE_STORE_FIELDS, } from "./_internal/server/features/stores/adapters.js";
|
|
1389
1389
|
export { getBrandForDetail } from "./_internal/server/features/brands/index.js";
|
|
1390
|
+
export { getFranchiseForDetail, listActiveFranchises, renderFranchiseOg, } from "./_internal/server/features/franchises/index.js";
|
|
1390
1391
|
export { getBlogPostForDetail } from "./_internal/server/features/blog/index.js";
|
|
1391
1392
|
export { getEventForDetail } from "./_internal/server/features/events/index.js";
|
|
1392
1393
|
export { getSublistingCategoryForDetail } from "./_internal/server/features/sublisting-categories/index.js";
|
package/package.json
CHANGED
package/scripts/seed-cli.mjs
CHANGED
|
@@ -195,6 +195,7 @@ const {
|
|
|
195
195
|
productsPrizeDrawsSeedData, productsClassifiedsSeedData, productsDigitalCodesSeedData, productsLiveItemsSeedData,
|
|
196
196
|
productsArtSeedData, productsStickersSeedData,
|
|
197
197
|
productsCatalogueSeedData,
|
|
198
|
+
FRANCHISE_BY_CATEGORY_ROOT,
|
|
198
199
|
ordersSeedData, reviewsSeedData, cartsSeedData, bidsSeedData,
|
|
199
200
|
couponsSeedData, couponUsageSeedData,
|
|
200
201
|
eventsSeedData, eventEntriesSeedData, payoutsSeedData, offersSeedData,
|
|
@@ -369,6 +370,35 @@ const COLLECTION_MAP = {
|
|
|
369
370
|
* signal, so a silent 5% here would hide exactly what it is meant to surface —
|
|
370
371
|
* and a wrong rate on a real invoice is worse than a failed load.
|
|
371
372
|
*/
|
|
373
|
+
/**
|
|
374
|
+
* Stamp `franchise` / `franchiseSlug` from the product's category ROOT.
|
|
375
|
+
*
|
|
376
|
+
* 🛑 Its OWN pass, deliberately NOT folded into `withDerivedCategoryFields`.
|
|
377
|
+
* That function early-returns on `gstRate && hsnCode && authenticity`, which
|
|
378
|
+
* every one of the 1,237 generated catalogue rows already satisfies — so a
|
|
379
|
+
* franchise derived in there would be silently skipped for 95% of the
|
|
380
|
+
* catalogue. That is the exact bug its own comment records happening when
|
|
381
|
+
* `authenticity` was added beside `gstRate`; adding a third field to the same
|
|
382
|
+
* condition is how it happens a third time.
|
|
383
|
+
*
|
|
384
|
+
* A root with no mapping yields NO franchise (living collectibles are not an
|
|
385
|
+
* IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
|
|
386
|
+
*/
|
|
387
|
+
function withFranchise(products) {
|
|
388
|
+
let stamped = 0;
|
|
389
|
+
const out = products.map((p) => {
|
|
390
|
+
if (p.franchiseSlug) return p;
|
|
391
|
+
const chain = Array.isArray(p.categorySlugs) ? p.categorySlugs : [];
|
|
392
|
+
const root = chain.length > 0 ? String(chain[chain.length - 1]) : null;
|
|
393
|
+
const f = root ? FRANCHISE_BY_CATEGORY_ROOT[root] : null;
|
|
394
|
+
if (!f) return p;
|
|
395
|
+
stamped++;
|
|
396
|
+
return { ...p, franchise: f.name, franchiseSlug: f.slug };
|
|
397
|
+
});
|
|
398
|
+
console.log(` • franchise stamped on ${stamped}/${products.length} products`);
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
372
402
|
function withDerivedCategoryFields(products) {
|
|
373
403
|
const catById = new Map((categoriesSeedData || []).map((c) => [String(c.id), c]));
|
|
374
404
|
const taxById = new Map((taxCodesSeedData || []).map((t) => [String(t.id), t]));
|
|
@@ -461,7 +491,7 @@ const SEED_DATA_MAP = {
|
|
|
461
491
|
// standalone brands collection/seed exists anymore.
|
|
462
492
|
categories: categoriesSeedData,
|
|
463
493
|
stores: storesSeedData,
|
|
464
|
-
products: withDerivedCategoryFields([
|
|
494
|
+
products: withFranchise(withDerivedCategoryFields([
|
|
465
495
|
...(productsStandardSeedData || []), ...(productsAuctionsSeedData || []), ...(productsPreordersSeedData || []),
|
|
466
496
|
...(productsPrizeDrawsSeedData || []), ...(productsClassifiedsSeedData || []), ...(productsDigitalCodesSeedData || []), ...(productsLiveItemsSeedData || []),
|
|
467
497
|
// art + stickers were written, exported from the seed barrel, and then never
|
|
@@ -474,7 +504,7 @@ const SEED_DATA_MAP = {
|
|
|
474
504
|
// reproducible after a full wipe; without it the collection drops from
|
|
475
505
|
// 1,039 to 71 the moment someone purges it.
|
|
476
506
|
...(productsCatalogueSeedData || []),
|
|
477
|
-
]),
|
|
507
|
+
])),
|
|
478
508
|
orders: ordersSeedData,
|
|
479
509
|
reviews: reviewsSeedData,
|
|
480
510
|
bids: bidsSeedData,
|