@mohasinac/appkit 4.45.0 โ 4.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
- package/dist/_internal/server/features/categories/live-counts.js +72 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
- package/dist/_internal/server/features/orders/adapters.js +29 -0
- package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
- package/dist/_internal/server/features/parts/assembly.js +246 -0
- package/dist/_internal/server/features/parts/data.d.ts +65 -0
- package/dist/_internal/server/features/parts/data.js +170 -0
- package/dist/_internal/server/features/parts/og.d.ts +34 -0
- package/dist/_internal/server/features/parts/og.js +66 -0
- package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
- package/dist/_internal/server/features/search/search-everything.js +120 -0
- package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
- package/dist/_internal/server/features/search/search-sources.js +138 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
- package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
- package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
- package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
- package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
- package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
- package/dist/_internal/shared/actions/action-registry.js +36 -0
- package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
- package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
- package/dist/_internal/shared/features/shipping/protections.js +127 -0
- package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
- package/dist/_internal/shared/search/part-aliases.js +104 -0
- package/dist/constants/api-endpoints.d.ts +18 -12
- package/dist/constants/api-endpoints.js +6 -8
- package/dist/constants/field-names.d.ts +0 -2
- package/dist/constants/field-names.js +0 -2
- package/dist/constants/index.d.ts +1 -1
- package/dist/constants/index.js +1 -1
- package/dist/core/index.d.ts +0 -3
- package/dist/core/index.js +0 -2
- package/dist/errors/messages.d.ts +0 -7
- package/dist/errors/messages.js +0 -8
- package/dist/features/admin/components/AdminSectionsView.js +83 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
- package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
- package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
- package/dist/features/admin/schemas/firestore.d.ts +15 -0
- package/dist/features/admin/types/index.d.ts +0 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +0 -2
- package/dist/features/categories/api/[id]/description-template/route.js +40 -1
- package/dist/features/categories/components/BrandDetailPageView.js +14 -3
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
- package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
- package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
- package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
- package/dist/features/categories/repository/categories.repository.d.ts +65 -0
- package/dist/features/categories/repository/categories.repository.js +168 -22
- package/dist/features/categories/repository/category-counts.d.ts +143 -0
- package/dist/features/categories/repository/category-counts.js +224 -0
- package/dist/features/categories/schemas/category-counts.d.ts +155 -0
- package/dist/features/categories/schemas/category-counts.js +116 -0
- package/dist/features/categories/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/schemas/firestore.js +0 -1
- package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
- package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
- package/dist/features/corporate/schemas/index.d.ts +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +13 -0
- package/dist/features/orders/types/index.d.ts +30 -0
- package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
- package/dist/features/parts/components/AssemblyDrawer.js +250 -0
- package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
- package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
- package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
- package/dist/features/parts/components/PartDetailPageView.js +20 -0
- package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
- package/dist/features/parts/components/PartsIndexPageView.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +257 -0
- package/dist/features/parts/schemas/firestore.js +181 -0
- package/dist/features/parts/utils/colourway-match.d.ts +62 -0
- package/dist/features/parts/utils/colourway-match.js +98 -0
- package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
- package/dist/features/parts/utils/part-breakdown.js +77 -0
- package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
- package/dist/features/products/components/ProductDetailPageView.js +22 -3
- package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
- package/dist/features/products/components/ProductTabsShell.js +4 -1
- package/dist/features/products/repository/category-assignment.d.ts +132 -0
- package/dist/features/products/repository/category-assignment.js +304 -0
- package/dist/features/products/repository/products.repository.d.ts +58 -1
- package/dist/features/products/repository/products.repository.js +255 -28
- package/dist/features/products/schemas/firestore.d.ts +147 -6
- package/dist/features/products/schemas/firestore.js +49 -0
- package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
- package/dist/features/search/components/SearchResultsPageView.js +21 -0
- package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
- package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
- package/dist/features/seller/components/SellerProductShell.js +30 -1
- package/dist/features/seller/components/SellerProductsView.js +129 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
- package/dist/index.d.ts +11 -16
- package/dist/index.js +26 -18
- package/dist/next/routing/route-map.d.ts +6 -2
- package/dist/next/routing/route-map.js +14 -1
- package/dist/providers/db-firebase/base.repository.d.ts +31 -0
- package/dist/providers/db-firebase/base.repository.js +31 -0
- package/dist/repositories/index.d.ts +1 -2
- package/dist/repositories/index.js +0 -1
- package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
- package/dist/seed/_helpers/category-content-generated.js +1837 -0
- package/dist/seed/_helpers/category-content.d.ts +29 -0
- package/dist/seed/_helpers/category-content.js +98 -1
- package/dist/seed/_helpers/category-forest.js +104 -24
- package/dist/seed/_helpers/category-parts.d.ts +9 -0
- package/dist/seed/_helpers/category-parts.js +4594 -0
- package/dist/seed/carousel-slides-seed-data.js +81 -8
- package/dist/seed/categories-seed-data.js +44 -6
- package/dist/seed/homepage-sections-seed-data.js +170 -9
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +13 -0
- package/dist/seed/model-composition-seed-data.d.ts +15 -0
- package/dist/seed/model-composition-seed-data.js +8918 -0
- package/dist/seed/part-glossary-seed-data.d.ts +15 -0
- package/dist/seed/part-glossary-seed-data.js +10729 -0
- package/dist/seed/products-standard-seed-data.js +52 -0
- package/dist/seed/seed-purpose.d.ts +103 -4
- package/dist/seed/seed-purpose.js +162 -11
- package/dist/seo/json-ld.d.ts +10 -1
- package/dist/seo/json-ld.js +9 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/id-generators.d.ts +30 -0
- package/dist/utils/id-generators.js +31 -1
- package/dist/utils/search-txt-builders.js +22 -0
- package/dist/utils/search-txt.d.ts +22 -0
- package/dist/utils/search-txt.js +17 -0
- package/dist/values/success-messages.d.ts +0 -4
- package/dist/values/success-messages.js +0 -5
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +292 -16
- package/dist/core/copilot-log.repository.d.ts +0 -44
- package/dist/core/copilot-log.repository.js +0 -61
- package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
- package/dist/features/copilot/components/AdminCopilotView.js +0 -51
- package/dist/features/copilot/components/index.d.ts +0 -2
- package/dist/features/copilot/components/index.js +0 -1
- package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
- package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
- package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
- package/dist/features/copilot/index.d.ts +0 -3
- package/dist/features/copilot/index.js +0 -3
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D12 โ reads for the public part surfaces.
|
|
3
|
+
*
|
|
4
|
+
* ๐ Wrapped in `React.cache` where a page+`generateMetadata` pair shares the
|
|
5
|
+
* call, and the NARROWEST shared call is cached rather than the exported one:
|
|
6
|
+
* `React.cache` memoises per argument list, so two call sites passing
|
|
7
|
+
* different arguments get two entries and dedupe nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { cache } from "react";
|
|
10
|
+
import { PART_GLOSSARY_COLLECTION, MODEL_COMPOSITION_COLLECTION, partGlossaryId, } from "../../../../features/parts/schemas/firestore.js";
|
|
11
|
+
import { getAdminDb } from "../../../../providers/db-firebase/index.js";
|
|
12
|
+
import { serverLogger } from "../../../../monitoring/server-logger.js";
|
|
13
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
14
|
+
/** The bucket cap. 87 is the largest real classification, so this clears it. */
|
|
15
|
+
export const PART_SLOT_PAGE_SIZE = 120;
|
|
16
|
+
/** One part, by `(slot, code|name)` โ the only key this collection has. */
|
|
17
|
+
export const getPartByKey = cache(async (slot, codeOrName) => {
|
|
18
|
+
try {
|
|
19
|
+
const snap = await getAdminDb()
|
|
20
|
+
.collection(PART_GLOSSARY_COLLECTION)
|
|
21
|
+
.doc(partGlossaryId(slot, codeOrName))
|
|
22
|
+
.get();
|
|
23
|
+
return snap.exists ? { id: snap.id, ...snap.data() } : null;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
const e = normalizeError(err);
|
|
27
|
+
serverLogger.warn("getPartByKey failed", { slot, codeOrName, error: e.message });
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* The models that use a part, hydrated.
|
|
33
|
+
*
|
|
34
|
+
* ๐ `getAll` over the ids the part ALREADY carries, not a query. `usedBy` is
|
|
35
|
+
* written by the generator, so this is one round trip for N ids with no index
|
|
36
|
+
* and no scan โ and it is bounded by how many models actually use the part
|
|
37
|
+
* (measured max: 20).
|
|
38
|
+
*/
|
|
39
|
+
export const getModelsUsingPart = cache(async (partId) => {
|
|
40
|
+
try {
|
|
41
|
+
const db = getAdminDb();
|
|
42
|
+
const part = await db.collection(PART_GLOSSARY_COLLECTION).doc(partId).get();
|
|
43
|
+
const ids = (part.data()?.usedBy ?? []).slice(0, 24);
|
|
44
|
+
if (ids.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const col = db.collection(MODEL_COMPOSITION_COLLECTION);
|
|
47
|
+
const snaps = await db.getAll(...ids.map((id) => col.doc(id)));
|
|
48
|
+
return snaps
|
|
49
|
+
.filter((s) => s.exists)
|
|
50
|
+
.map((s) => ({ id: s.id, ...s.data() }));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const e = normalizeError(err);
|
|
54
|
+
serverLogger.warn("getModelsUsingPart failed", { partId, error: e.message });
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
/** One model's composition, by the corpus doc id. */
|
|
59
|
+
export const getCompositionById = cache(async (id) => {
|
|
60
|
+
try {
|
|
61
|
+
const snap = await getAdminDb().collection(MODEL_COMPOSITION_COLLECTION).doc(id).get();
|
|
62
|
+
return snap.exists ? { id: snap.id, ...snap.data() } : null;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const e = normalizeError(err);
|
|
66
|
+
serverLogger.warn("getCompositionById failed", { id, error: e.message });
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* Hydrate the glossary rows a composition's slots point at, so a model page can
|
|
72
|
+
* link each slot and show its weight.
|
|
73
|
+
*
|
|
74
|
+
* ONE `getAll` for every slot โ a per-slot read would be five round trips on a
|
|
75
|
+
* page that already has its own.
|
|
76
|
+
*/
|
|
77
|
+
export const getPartsForComposition = cache(async (compositionId) => {
|
|
78
|
+
const out = new Map();
|
|
79
|
+
try {
|
|
80
|
+
const db = getAdminDb();
|
|
81
|
+
const comp = await getCompositionById(compositionId);
|
|
82
|
+
const ids = (comp?.slots ?? [])
|
|
83
|
+
.map((s) => s.partId)
|
|
84
|
+
.filter((id) => !!id);
|
|
85
|
+
if (ids.length === 0)
|
|
86
|
+
return out;
|
|
87
|
+
const col = db.collection(PART_GLOSSARY_COLLECTION);
|
|
88
|
+
const snaps = await db.getAll(...[...new Set(ids)].map((id) => col.doc(id)));
|
|
89
|
+
for (const s of snaps) {
|
|
90
|
+
if (s.exists)
|
|
91
|
+
out.set(s.id, { id: s.id, ...s.data() });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const e = normalizeError(err);
|
|
96
|
+
serverLogger.warn("getPartsForComposition failed", { compositionId, error: e.message });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* Compositions for a named-model CATEGORY.
|
|
102
|
+
*
|
|
103
|
+
* ๐ ONE INDEXED EQUALITY on `categoryId`, joined at SEED-GENERATION time.
|
|
104
|
+
*
|
|
105
|
+
* The first version queried `searchTxt` on `modelComposition` โ a field that
|
|
106
|
+
* collection does not have, so it would have matched NOTHING while looking
|
|
107
|
+
* like a working read. The two id spaces genuinely disagree
|
|
108
|
+
* (`gen1-plastic-dragoon-f` vs `category-dragoon-f`), so a join is required;
|
|
109
|
+
* doing it in the generator makes it deterministic, reviewable in the
|
|
110
|
+
* committed seed, and one equality here instead of a name query.
|
|
111
|
+
*
|
|
112
|
+
* Measured: **210 of 259** compositions join. The join is on the normalised
|
|
113
|
+
* name, and on the name with its parenthetical expansion removed โ the corpus
|
|
114
|
+
* titles HMS beys `Aero Knight MS (Aero Knight Metal Sky)` where the category
|
|
115
|
+
* is "Aero Knight MS", and without that second form only 150 matched.
|
|
116
|
+
*
|
|
117
|
+
* ๐ A non-match leaves `categoryId` unset and this returns `[]`, so the model
|
|
118
|
+
* page shows no composition. That is honest: a fuzzy match could attach
|
|
119
|
+
* ANOTHER bey's parts, and a wrong part list is worse than none. Reconciling
|
|
120
|
+
* the remaining 49 is E3's job.
|
|
121
|
+
*/
|
|
122
|
+
export const getCompositionsForCategory = cache(async (categoryId) => {
|
|
123
|
+
if (!categoryId)
|
|
124
|
+
return [];
|
|
125
|
+
try {
|
|
126
|
+
const snap = await getAdminDb()
|
|
127
|
+
.collection(MODEL_COMPOSITION_COLLECTION)
|
|
128
|
+
.where("categoryId", "==", categoryId)
|
|
129
|
+
.limit(8)
|
|
130
|
+
.get();
|
|
131
|
+
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
const e = normalizeError(err);
|
|
135
|
+
serverLogger.warn("getCompositionsForCategory failed", { categoryId, error: e.message });
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
/**
|
|
140
|
+
* Every part in one classification, alphabetical.
|
|
141
|
+
*
|
|
142
|
+
* ๐ ONE equality on `slot`, bounded. The biggest bucket is 87 rows (attack
|
|
143
|
+
* rings) and the smallest 4 (armour) โ measured, not assumed โ so a page size
|
|
144
|
+
* of 120 shows every one of them today without a pager, and `truncated` tells
|
|
145
|
+
* the view to stop claiming a total the moment that stops being true.
|
|
146
|
+
*
|
|
147
|
+
* Firestore needs no composite index for one equality plus an `orderBy` on
|
|
148
|
+
* the SAME field it is already keyed by document id withinโฆ except it does
|
|
149
|
+
* for `(slot ==, name asc)`. So the sort is applied IN MEMORY: 87 rows is
|
|
150
|
+
* nothing to sort, and a declared index that exists only to order a bucket
|
|
151
|
+
* this small is an index to deploy, wait on, and remember.
|
|
152
|
+
*/
|
|
153
|
+
export const listPartsBySlot = cache(async (slot) => {
|
|
154
|
+
try {
|
|
155
|
+
const snap = await getAdminDb()
|
|
156
|
+
.collection(PART_GLOSSARY_COLLECTION)
|
|
157
|
+
.where("slot", "==", slot)
|
|
158
|
+
.limit(PART_SLOT_PAGE_SIZE)
|
|
159
|
+
.get();
|
|
160
|
+
const parts = snap.docs
|
|
161
|
+
.map((d) => ({ id: d.id, ...d.data() }))
|
|
162
|
+
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
|
163
|
+
return { parts, truncated: snap.size >= PART_SLOT_PAGE_SIZE };
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const e = normalizeError(err);
|
|
167
|
+
serverLogger.warn("listPartsBySlot failed", { slot, error: e.message });
|
|
168
|
+
return { parts: [], truncated: false };
|
|
169
|
+
}
|
|
170
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D12 โ OG cards for the part surfaces.
|
|
3
|
+
*
|
|
4
|
+
* โโ ๐ NO IMAGE FETCHES, DELIBERATELY โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
5
|
+
*
|
|
6
|
+
* Every other OG renderer here pulls a cover image through `resolveOgImageUrl`.
|
|
7
|
+
* These do not, and that is the one design decision worth stating: the project
|
|
8
|
+
* was suspended for **Fast Origin Transfer at 5.25ร its allowance** (52.52 GB
|
|
9
|
+
* against 10 GB), traced to render-time image proxying (Root Cause #104). A
|
|
10
|
+
* glossary of 753 parts across 20 classifications is 774 OG routes; one remote
|
|
11
|
+
* fetch each, on a crawler sweep, is exactly the amplification that was just
|
|
12
|
+
* removed. Type on a gradient costs one lambda and nothing else.
|
|
13
|
+
*
|
|
14
|
+
* It also costs nothing in usefulness: a part has no photograph in our data.
|
|
15
|
+
* The honest card is its name, what kind of part it is, its weight and how
|
|
16
|
+
* many models use it โ all four of which are facts we hold.
|
|
17
|
+
*
|
|
18
|
+
* โโ ๐ NO MONEY ON THE CANVAS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
19
|
+
*
|
|
20
|
+
* All eight money-bearing OG renderers were stripped for this reason: an OG
|
|
21
|
+
* image is a public image URL with the amount burned into the pixels, so no
|
|
22
|
+
* sign-in gate reaches it and no paywall markup describes it. A part card
|
|
23
|
+
* carries no price and never should, even though its page lists listings.
|
|
24
|
+
*/
|
|
25
|
+
import type { ReactElement } from "react";
|
|
26
|
+
import { type PartGlossaryDocument, type PartSlot } from "../../../../features/parts/schemas/firestore";
|
|
27
|
+
/** One part's card. */
|
|
28
|
+
export declare function renderPartOg(part: PartGlossaryDocument | null | undefined, usedByCount: number, opts: {
|
|
29
|
+
siteName: string;
|
|
30
|
+
}): ReactElement;
|
|
31
|
+
/** One classification's card. */
|
|
32
|
+
export declare function renderPartClassificationOg(slot: PartSlot, count: number, opts: {
|
|
33
|
+
siteName: string;
|
|
34
|
+
}): ReactElement;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PART_SLOT_LABEL, } from "../../../../features/parts/schemas/firestore.js";
|
|
3
|
+
const BG = "#0f172a";
|
|
4
|
+
const GRADIENT = "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)";
|
|
5
|
+
const MUTED = "#94a3b8";
|
|
6
|
+
const ACCENT = "#5eead4";
|
|
7
|
+
function shell(eyebrow, title, detail, siteName) {
|
|
8
|
+
return (_jsxs("div", { style: {
|
|
9
|
+
display: "flex",
|
|
10
|
+
width: "100%",
|
|
11
|
+
height: "100%",
|
|
12
|
+
background: BG,
|
|
13
|
+
fontFamily: "sans-serif",
|
|
14
|
+
position: "relative",
|
|
15
|
+
overflow: "hidden",
|
|
16
|
+
alignItems: "center",
|
|
17
|
+
justifyContent: "center",
|
|
18
|
+
}, children: [_jsx("div", { style: { position: "absolute", inset: 0, background: GRADIENT } }), _jsxs("div", { style: {
|
|
19
|
+
position: "relative",
|
|
20
|
+
display: "flex",
|
|
21
|
+
flexDirection: "column",
|
|
22
|
+
alignItems: "center",
|
|
23
|
+
gap: 24,
|
|
24
|
+
padding: "60px",
|
|
25
|
+
textAlign: "center",
|
|
26
|
+
}, children: [_jsx("div", { style: {
|
|
27
|
+
fontSize: 20,
|
|
28
|
+
color: ACCENT,
|
|
29
|
+
fontWeight: 600,
|
|
30
|
+
letterSpacing: 3,
|
|
31
|
+
textTransform: "uppercase",
|
|
32
|
+
}, children: eyebrow }), _jsx("div", { style: {
|
|
33
|
+
fontSize: title.length > 34 ? 56 : 76,
|
|
34
|
+
color: "#ffffff",
|
|
35
|
+
fontWeight: 700,
|
|
36
|
+
lineHeight: 1.1,
|
|
37
|
+
}, children: title }), detail && _jsx("div", { style: { fontSize: 28, color: MUTED }, children: detail }), _jsx("div", { style: { fontSize: 20, color: MUTED, marginTop: 8 }, children: siteName })] })] }));
|
|
38
|
+
}
|
|
39
|
+
/** One part's card. */
|
|
40
|
+
export function renderPartOg(part, usedByCount, opts) {
|
|
41
|
+
if (!part)
|
|
42
|
+
return shell("Parts", "Beyblade parts", "", opts.siteName);
|
|
43
|
+
const classification = PART_SLOT_LABEL[part.slot] ?? part.classification ?? part.slot;
|
|
44
|
+
/*
|
|
45
|
+
* Only the facts we have. 23 of 753 parts carry a code and 98 carry a
|
|
46
|
+
* weight, so each clause is conditional โ a card reading "0 g" or an empty
|
|
47
|
+
* bracket would be a claim we cannot support.
|
|
48
|
+
*/
|
|
49
|
+
const bits = [
|
|
50
|
+
part.code ? `Code ${part.code}` : null,
|
|
51
|
+
typeof part.weightG === "number" ? `${part.weightG} g` : null,
|
|
52
|
+
usedByCount > 0
|
|
53
|
+
? `Used by ${usedByCount} ${usedByCount === 1 ? "model" : "models"}`
|
|
54
|
+
: null,
|
|
55
|
+
].filter((b) => b !== null);
|
|
56
|
+
return shell(classification, part.name, bits.join(" ยท "), opts.siteName);
|
|
57
|
+
}
|
|
58
|
+
/** One classification's card. */
|
|
59
|
+
export function renderPartClassificationOg(slot, count, opts) {
|
|
60
|
+
const label = PART_SLOT_LABEL[slot] ?? slot;
|
|
61
|
+
return shell("Parts database", label,
|
|
62
|
+
// `count` comes from the same bounded read the page renders, so the number
|
|
63
|
+
// on the card is the number on the page. A separate count query would be a
|
|
64
|
+
// second source that could disagree.
|
|
65
|
+
count > 0 ? `${count} recorded` : "", opts.siteName);
|
|
66
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { FirestoreValue } from "../../../../schemas/types";
|
|
2
|
+
/** One matched row, deliberately minimal. */
|
|
3
|
+
export interface SearchHit {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
/** Where clicking goes. Built by the caller's route map, never guessed. */
|
|
7
|
+
href: string;
|
|
8
|
+
/** A one-line qualifier โ the category path, the store name, the FAQ category. */
|
|
9
|
+
detail?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SearchGroup {
|
|
12
|
+
/** Stable key โ `products`, `categories`, โฆ matching the route map. */
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
hits: SearchHit[];
|
|
16
|
+
/**
|
|
17
|
+
* ๐ `true` when the bounded fetch SATURATED, so the group's count is a
|
|
18
|
+
* FLOOR and must render as "6+" rather than "6". Same honesty rule as
|
|
19
|
+
* `truncated` on the listing queries โ a saturated fetch reporting an exact
|
|
20
|
+
* total is a confident wrong number.
|
|
21
|
+
*/
|
|
22
|
+
truncated: boolean;
|
|
23
|
+
/** The listing page for this resource, pre-filtered by the same query. */
|
|
24
|
+
moreHref: string;
|
|
25
|
+
}
|
|
26
|
+
export interface SearchEverythingResult {
|
|
27
|
+
q: string;
|
|
28
|
+
/** Parsed terms โ empty for a query that normalises away, e.g. "!!!". */
|
|
29
|
+
terms: string[];
|
|
30
|
+
groups: SearchGroup[];
|
|
31
|
+
totalHits: number;
|
|
32
|
+
}
|
|
33
|
+
export interface SearchSource {
|
|
34
|
+
key: string;
|
|
35
|
+
label: string;
|
|
36
|
+
/** The collection to scan. */
|
|
37
|
+
collection: string;
|
|
38
|
+
/** Fields to project โ a search page needs a title and a link, not a document. */
|
|
39
|
+
select: readonly string[];
|
|
40
|
+
/** Build a hit from a projected document. */
|
|
41
|
+
toHit: (id: string, data: SearchDocData) => SearchHit | null;
|
|
42
|
+
/** The pre-filtered listing page. */
|
|
43
|
+
moreHref: (q: string) => string;
|
|
44
|
+
/** Extra equality narrowing, e.g. `status == published`. */
|
|
45
|
+
where?: ReadonlyArray<readonly [string, unknown]>;
|
|
46
|
+
}
|
|
47
|
+
/** A projected Firestore document's fields. */
|
|
48
|
+
export type SearchDocData = Record<string, FirestoreValue>;
|
|
49
|
+
/** A projected document โ the only shape this module reads. */
|
|
50
|
+
interface SearchDoc {
|
|
51
|
+
id: string;
|
|
52
|
+
data(): SearchDocData;
|
|
53
|
+
}
|
|
54
|
+
interface SearchQueryRef {
|
|
55
|
+
where(field: string, op: string, value: unknown): SearchQueryRef;
|
|
56
|
+
select(...fields: string[]): SearchQueryRef;
|
|
57
|
+
limit(n: number): SearchQueryRef;
|
|
58
|
+
get(): Promise<{
|
|
59
|
+
docs: SearchDoc[];
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
interface MinimalDb {
|
|
63
|
+
collection(name: string): SearchQueryRef;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run every source in parallel and group the results.
|
|
67
|
+
*
|
|
68
|
+
* ๐ A source that THROWS yields an empty group and is logged โ it never takes
|
|
69
|
+
* the page down. But the group is still RETURNED with `truncated: false` and no
|
|
70
|
+
* hits, which is indistinguishable from "nothing matched"โฆ so the failure is
|
|
71
|
+
* logged loudly, because that is the one thing this cannot show the visitor
|
|
72
|
+
* without inventing an error state for a page that mostly worked.
|
|
73
|
+
*/
|
|
74
|
+
export declare function searchEverything(db: MinimalDb, q: string, sources: readonly SearchSource[]): Promise<SearchEverythingResult>;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D2 Fix 4 โ CROSS-RESOURCE search.
|
|
3
|
+
*
|
|
4
|
+
* ๐ `/search` is a pure REDIRECT today: it maps each of 15
|
|
5
|
+
* `SearchResourceType` values to a browse route and `redirect()`s, `noindex`.
|
|
6
|
+
* So there is **no cross-resource search at all** โ the dropdown picks which
|
|
7
|
+
* single listing page to send you to, and a buyer searching "dranzer" cannot
|
|
8
|
+
* see that we have a product, an auction, a blog post and an FAQ about it.
|
|
9
|
+
*
|
|
10
|
+
* โโ WHY THIS IS AFFORDABLE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
11
|
+
*
|
|
12
|
+
* N bounded parallel queries, which is exactly the shape section 4.4's availability
|
|
13
|
+
* fan-out already uses (five clauses, 250 docs worst case). Every public
|
|
14
|
+
* collection carries `searchTxt` at **100% coverage** โ measured 2026-10-12:
|
|
15
|
+
* products 71/71, stores 4/4, categories 416/416, blogPosts 20/20, events
|
|
16
|
+
* 12/12, faqs 85/85 โ so each group is one `array-contains` push-down on the
|
|
17
|
+
* longest term plus an in-memory AND-refine, the same mechanism every listing
|
|
18
|
+
* page uses.
|
|
19
|
+
*
|
|
20
|
+
* One `Promise.all`, so the whole page is ONE round trip's latency rather than
|
|
21
|
+
* six, and Rule #6's budget is about sequential round trips.
|
|
22
|
+
*
|
|
23
|
+
* โโ ๐ NO PRICES, AND THAT IS A DESIGN DECISION โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
24
|
+
*
|
|
25
|
+
* The result rows carry a title, a type and a URL โ never an amount. Prices
|
|
26
|
+
* are hidden from signed-out visitors, and a cross-resource page is the one
|
|
27
|
+
* surface where gating every row would be most of the markup. Showing names
|
|
28
|
+
* and linking through is honest, needs no `<GatedPrice>` wrapper, and cannot
|
|
29
|
+
* leak: there is nothing to leak.
|
|
30
|
+
*
|
|
31
|
+
* โโ ๐ WHAT IT DOES NOT REUSE, AND WHY โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
32
|
+
*
|
|
33
|
+
* NOT `SearchRepository`. That class builds `title_=${q}` โ a case-sensitive
|
|
34
|
+
* STARTS-WITH on `title` alone โ so "dranzer" would not find "Beyblade
|
|
35
|
+
* Original Dranzer S" and "flat" would find nothing at all. It is reachable
|
|
36
|
+
* only through `/api/search`, whose own hook (`useSearch`) has zero consumers
|
|
37
|
+
* outside barrel re-exports. Recorded rather than deleted here; see F1.
|
|
38
|
+
*/
|
|
39
|
+
import { planSearchTxt } from "../../../../utils/search-txt-query.js";
|
|
40
|
+
import { parseSearchTxtQuery, SEARCH_TXT_FIELD } from "../../../../utils/search-txt.js";
|
|
41
|
+
import { serverLogger } from "../../../../monitoring/server-logger.js";
|
|
42
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
43
|
+
/** Per group, so one slow or failing collection cannot dominate the page. */
|
|
44
|
+
const PER_GROUP = 6;
|
|
45
|
+
/** The bounded window each push-down reads before the in-memory refine. */
|
|
46
|
+
const SCAN = 60;
|
|
47
|
+
/**
|
|
48
|
+
* Run every source in parallel and group the results.
|
|
49
|
+
*
|
|
50
|
+
* ๐ A source that THROWS yields an empty group and is logged โ it never takes
|
|
51
|
+
* the page down. But the group is still RETURNED with `truncated: false` and no
|
|
52
|
+
* hits, which is indistinguishable from "nothing matched"โฆ so the failure is
|
|
53
|
+
* logged loudly, because that is the one thing this cannot show the visitor
|
|
54
|
+
* without inventing an error state for a page that mostly worked.
|
|
55
|
+
*/
|
|
56
|
+
export async function searchEverything(db, q, sources) {
|
|
57
|
+
const plan = planSearchTxt(q);
|
|
58
|
+
const terms = parseSearchTxtQuery(q ?? "");
|
|
59
|
+
/*
|
|
60
|
+
* ๐ A query that normalises to nothing returns NOTHING, never everything.
|
|
61
|
+
* `"!!!"` and `"โ"` have no tokens, and emitting no clause would make the
|
|
62
|
+
* push-down match every document in every collection โ which is exactly the
|
|
63
|
+
* bug A2 fixed on `/api/products`, where `"!!!"` returned the whole
|
|
64
|
+
* published catalogue AS SEARCH RESULTS.
|
|
65
|
+
*/
|
|
66
|
+
if (!plan.head) {
|
|
67
|
+
return { q: q ?? "", terms, groups: [], totalHits: 0 };
|
|
68
|
+
}
|
|
69
|
+
const groups = await Promise.all(sources.map(async (src) => {
|
|
70
|
+
const base = {
|
|
71
|
+
key: src.key,
|
|
72
|
+
label: src.label,
|
|
73
|
+
hits: [],
|
|
74
|
+
truncated: false,
|
|
75
|
+
moreHref: src.moreHref(q),
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
let ref = db.collection(src.collection);
|
|
79
|
+
for (const [field, value] of src.where ?? [])
|
|
80
|
+
ref = ref.where(field, "==", value);
|
|
81
|
+
const snap = await ref
|
|
82
|
+
.where(SEARCH_TXT_FIELD, "array-contains", plan.head)
|
|
83
|
+
.select(...src.select, SEARCH_TXT_FIELD)
|
|
84
|
+
.limit(SCAN)
|
|
85
|
+
.get();
|
|
86
|
+
// AND-refine the remaining terms in memory โ one push-down per query is
|
|
87
|
+
// all Firestore gives, so the rest is a filter over a bounded window.
|
|
88
|
+
const matched = snap.docs.filter((d) => {
|
|
89
|
+
const txt = d.data()[SEARCH_TXT_FIELD] ?? [];
|
|
90
|
+
return plan.rest.every((t) => txt.includes(t));
|
|
91
|
+
});
|
|
92
|
+
const hits = matched
|
|
93
|
+
.map((d) => src.toHit(d.id, d.data()))
|
|
94
|
+
.filter((h) => h !== null);
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
hits: hits.slice(0, PER_GROUP),
|
|
98
|
+
// Saturated window OR more matches than we show โ either way the
|
|
99
|
+
// count on screen is a floor.
|
|
100
|
+
truncated: snap.docs.length >= SCAN || hits.length > PER_GROUP,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const e = normalizeError(err);
|
|
105
|
+
serverLogger.warn("searchEverything: a source failed and renders as empty", {
|
|
106
|
+
source: src.key,
|
|
107
|
+
collection: src.collection,
|
|
108
|
+
error: e.message,
|
|
109
|
+
});
|
|
110
|
+
return base;
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
const withHits = groups.filter((g) => g.hits.length > 0);
|
|
114
|
+
return {
|
|
115
|
+
q: q ?? "",
|
|
116
|
+
terms,
|
|
117
|
+
groups: withHits,
|
|
118
|
+
totalHits: withHits.reduce((n, g) => n + g.hits.length, 0),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D2 Fix 4 โ which collections `/search` spans, and how each row becomes a hit.
|
|
3
|
+
*
|
|
4
|
+
* ๐ SIX SOURCES, not fifteen. The redirect page maps 15 `SearchResourceType`
|
|
5
|
+
* values, but nine of them are LISTING TYPES inside `products` โ auctions,
|
|
6
|
+
* pre-orders, prize-draws, classified, digital-codes, live, art, bundles. One
|
|
7
|
+
* `products` query covers all of them, and a per-type query would be nine
|
|
8
|
+
* fan-outs returning overlapping rows from one collection.
|
|
9
|
+
*
|
|
10
|
+
* ๐ Every field here was read off the live documents, not assumed. `stores`
|
|
11
|
+
* uses `storeName` (not `name`), `faqs` uses `question` + `seo.slug`, and
|
|
12
|
+
* `categories` must be filtered to listing rows because brands and bundles
|
|
13
|
+
* share the collection โ the same trap that made the `/products` category
|
|
14
|
+
* facet offer "Takara-Tomy" as a browsable category (A1 item 4).
|
|
15
|
+
*/
|
|
16
|
+
import type { SearchSource } from "./search-everything";
|
|
17
|
+
export declare const SEARCH_SOURCES: readonly SearchSource[];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : null;
|
|
2
|
+
const withQ = (base, q) => q.trim() ? `${base}?q=${encodeURIComponent(q.trim())}` : base;
|
|
3
|
+
export const SEARCH_SOURCES = [
|
|
4
|
+
{
|
|
5
|
+
key: "products",
|
|
6
|
+
label: "Listings",
|
|
7
|
+
collection: "products",
|
|
8
|
+
/*
|
|
9
|
+
* ๐ `status == published` ONLY. Availability is a per-row predicate over
|
|
10
|
+
* nine listing types (section 4.4: "available" is negation-shaped and no query
|
|
11
|
+
* expresses it), so a sold-out row can appear here โ and that is correct
|
|
12
|
+
* for a search page, which answers "do you have this" rather than "can I
|
|
13
|
+
* buy it right now". The listing page it links to applies the scope.
|
|
14
|
+
*/
|
|
15
|
+
where: [["status", "published"]],
|
|
16
|
+
select: ["title", "slug", "listingType", "categoryNames", "brand"],
|
|
17
|
+
toHit: (id, d) => {
|
|
18
|
+
const title = str(d.title);
|
|
19
|
+
if (!title)
|
|
20
|
+
return null;
|
|
21
|
+
const names = Array.isArray(d.categoryNames) ? d.categoryNames : [];
|
|
22
|
+
// The chain is stored LEAF-FIRST, so [0] is the most specific label.
|
|
23
|
+
const detail = [str(d.brand), names[0]].filter(Boolean).join(" ยท ");
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
title,
|
|
27
|
+
href: `/products/${str(d.slug) ?? id}`,
|
|
28
|
+
...(detail ? { detail } : {}),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
moreHref: (q) => withQ("/products", q),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "categories",
|
|
35
|
+
label: "Categories",
|
|
36
|
+
collection: "categories",
|
|
37
|
+
select: ["name", "categoryType", "tier", "categoryNames"],
|
|
38
|
+
toHit: (id, d) => {
|
|
39
|
+
const name = str(d.name);
|
|
40
|
+
if (!name)
|
|
41
|
+
return null;
|
|
42
|
+
/*
|
|
43
|
+
* ๐ BRANDS AND BUNDLES SHARE THIS COLLECTION, and a plain listing
|
|
44
|
+
* category OMITS `categoryType` entirely โ so this cannot be a query
|
|
45
|
+
* clause and has to be an in-memory test. Exactly the situation that
|
|
46
|
+
* made the `/products` facet offer "Takara-Tomy" and "Burst Battlers
|
|
47
|
+
* Pack" as browsable categories (A1 item 4): 9 of its 11 options were
|
|
48
|
+
* not categories.
|
|
49
|
+
*
|
|
50
|
+
* Brands are their own group below, so they are excluded here rather
|
|
51
|
+
* than dropped.
|
|
52
|
+
*/
|
|
53
|
+
const type = str(d.categoryType);
|
|
54
|
+
if (type && type !== "sublisting")
|
|
55
|
+
return null;
|
|
56
|
+
return { id, title: name, href: `/categories/${id}` };
|
|
57
|
+
},
|
|
58
|
+
moreHref: (q) => withQ("/categories", q),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "brands",
|
|
62
|
+
label: "Brands",
|
|
63
|
+
collection: "categories",
|
|
64
|
+
// The one source with a real discriminator value to filter on.
|
|
65
|
+
where: [["categoryType", "brand"]],
|
|
66
|
+
select: ["name", "brandCountry"],
|
|
67
|
+
toHit: (id, d) => {
|
|
68
|
+
const name = str(d.name);
|
|
69
|
+
if (!name)
|
|
70
|
+
return null;
|
|
71
|
+
const detail = str(d.brandCountry);
|
|
72
|
+
return { id, title: name, href: `/brands/${id}`, ...(detail ? { detail } : {}) };
|
|
73
|
+
},
|
|
74
|
+
moreHref: (q) => withQ("/brands", q),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: "stores",
|
|
78
|
+
label: "Stores",
|
|
79
|
+
collection: "stores",
|
|
80
|
+
where: [["status", "active"]],
|
|
81
|
+
// ๐ `storeName`, not `name` โ read off the live documents.
|
|
82
|
+
select: ["storeName", "storeCategory"],
|
|
83
|
+
toHit: (id, d) => {
|
|
84
|
+
const name = str(d.storeName);
|
|
85
|
+
if (!name)
|
|
86
|
+
return null;
|
|
87
|
+
return { id, title: name, href: `/stores/${id}` };
|
|
88
|
+
},
|
|
89
|
+
moreHref: (q) => withQ("/stores", q),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "blog",
|
|
93
|
+
label: "Articles",
|
|
94
|
+
collection: "blogPosts",
|
|
95
|
+
where: [["status", "published"]],
|
|
96
|
+
select: ["title", "slug", "category"],
|
|
97
|
+
toHit: (id, d) => {
|
|
98
|
+
const title = str(d.title);
|
|
99
|
+
if (!title)
|
|
100
|
+
return null;
|
|
101
|
+
const detail = str(d.category);
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
title,
|
|
105
|
+
href: `/blog/${str(d.slug) ?? id}`,
|
|
106
|
+
...(detail ? { detail } : {}),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
moreHref: (q) => withQ("/blog", q),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: "faqs",
|
|
113
|
+
label: "Help & FAQs",
|
|
114
|
+
collection: "faqs",
|
|
115
|
+
where: [["isActive", true]],
|
|
116
|
+
select: ["question", "category", "seo"],
|
|
117
|
+
toHit: (id, d) => {
|
|
118
|
+
const question = str(d.question);
|
|
119
|
+
if (!question)
|
|
120
|
+
return null;
|
|
121
|
+
const category = str(d.category);
|
|
122
|
+
/*
|
|
123
|
+
* ๐ `/faqs/[category]`, never a question slug. The route matches on
|
|
124
|
+
* `CATEGORY_KEYS.includes(category)` and falls back to "all", so a slug
|
|
125
|
+
* silently lands the reader on the full index โ and the documents DO
|
|
126
|
+
* carry a `seo.slug` of exactly that shape, which makes the wrong link
|
|
127
|
+
* the obvious one. Same trap the authenticity hero slide hit.
|
|
128
|
+
*/
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
title: question,
|
|
132
|
+
href: category ? `/faqs/${category}` : "/faqs",
|
|
133
|
+
...(category ? { detail: category.replace(/_/g, " ") } : {}),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
moreHref: (q) => withQ("/faqs", q),
|
|
137
|
+
},
|
|
138
|
+
];
|