@mohasinac/appkit 4.8.1 → 4.9.1
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/features/categories/components/CategoryDetailPageView.js +24 -18
- package/dist/features/categories/components/CategoryGrid.js +5 -1
- package/dist/features/categories/repository/categories.repository.d.ts +8 -0
- package/dist/features/categories/repository/categories.repository.js +21 -0
- package/dist/features/categories/types/index.d.ts +4 -0
- package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
- package/dist/features/products/components/ProductForm.js +4 -2
- package/dist/features/tester/seed-data/products-tester-seed-data.js +14 -5
- package/package.json +1 -1
|
@@ -16,53 +16,59 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
16
16
|
const category = await categoriesRepository
|
|
17
17
|
.getCategoryBySlug(slug)
|
|
18
18
|
.catch(() => undefined);
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// Roll up descendant categories so a parent category page shows products
|
|
20
|
+
// filed under any of its children too, not just products tagged with the
|
|
21
|
+
// parent's own id — parentIds stores the full ancestor chain, so a single
|
|
22
|
+
// array-contains query already returns the whole subtree (see
|
|
23
|
+
// categoriesRepository.getDescendantIds).
|
|
24
|
+
const descendantIds = category?.id
|
|
25
|
+
? await categoriesRepository.getDescendantIds(category.id).catch(() => [])
|
|
26
|
+
: [];
|
|
27
|
+
const categoriesIn = category?.id ? [category.id, ...descendantIds] : null;
|
|
22
28
|
const [productsResult, auctionsCountResult, preOrdersCountResult, prizeDrawsCountResult, bundlesResult, childCategories, rootSiblingCategories] = await Promise.all([
|
|
23
|
-
|
|
29
|
+
categoriesIn
|
|
24
30
|
? productRepository
|
|
25
31
|
.list({
|
|
26
|
-
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"),
|
|
32
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "standard")),
|
|
27
33
|
sorts: sortBy("createdAt", "DESC"),
|
|
28
34
|
page: 1,
|
|
29
35
|
pageSize: 24,
|
|
30
|
-
})
|
|
36
|
+
}, { categoriesIn })
|
|
31
37
|
.catch(() => null)
|
|
32
38
|
: Promise.resolve(null),
|
|
33
|
-
|
|
39
|
+
categoriesIn
|
|
34
40
|
? productRepository
|
|
35
41
|
.list({
|
|
36
|
-
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"),
|
|
42
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "auction")),
|
|
37
43
|
sorts: sortBy("auctionEndDate", "ASC"),
|
|
38
44
|
page: 1,
|
|
39
45
|
pageSize: 1,
|
|
40
|
-
})
|
|
46
|
+
}, { categoriesIn })
|
|
41
47
|
.catch(() => null)
|
|
42
48
|
: Promise.resolve(null),
|
|
43
|
-
|
|
49
|
+
categoriesIn
|
|
44
50
|
? productRepository
|
|
45
51
|
.list({
|
|
46
|
-
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"),
|
|
52
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "pre-order")),
|
|
47
53
|
sorts: sortBy("createdAt", "DESC"),
|
|
48
54
|
page: 1,
|
|
49
55
|
pageSize: 1,
|
|
50
|
-
})
|
|
56
|
+
}, { categoriesIn })
|
|
51
57
|
.catch(() => null)
|
|
52
58
|
: Promise.resolve(null),
|
|
53
|
-
|
|
59
|
+
categoriesIn
|
|
54
60
|
? productRepository
|
|
55
61
|
.list({
|
|
56
|
-
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"),
|
|
62
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "prize-draw")),
|
|
57
63
|
sorts: sortBy("createdAt", "DESC"),
|
|
58
64
|
page: 1,
|
|
59
65
|
pageSize: 1,
|
|
60
|
-
})
|
|
66
|
+
}, { categoriesIn })
|
|
61
67
|
.catch(() => null)
|
|
62
68
|
: Promise.resolve(null),
|
|
63
69
|
// SB-UNI-D — bundles fetched from the categories collection. We pull
|
|
64
70
|
// all active bundle rows; the carousel filters by category affinity.
|
|
65
|
-
|
|
71
|
+
categoriesIn
|
|
66
72
|
? categoriesRepository
|
|
67
73
|
.listByType("bundle", { activeOnly: true, limit: 50 })
|
|
68
74
|
.catch(() => [])
|
|
@@ -112,8 +118,8 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
112
118
|
averageRating: s.stats?.averageRating,
|
|
113
119
|
createdAt: s.createdAt,
|
|
114
120
|
}));
|
|
115
|
-
const productCount = productsResult?.total ?? category?.metrics?.productCount ?? 0;
|
|
116
|
-
const auctionCount = auctionsCountResult?.total ?? category?.metrics?.auctionCount ?? 0;
|
|
121
|
+
const productCount = productsResult?.total ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
|
|
122
|
+
const auctionCount = auctionsCountResult?.total ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
|
|
117
123
|
const preOrderCount = preOrdersCountResult?.total ?? 0;
|
|
118
124
|
const prizeDrawCount = prizeDrawsCountResult?.total ?? 0;
|
|
119
125
|
const bundleCount = bundlesResult?.length ?? 0;
|
|
@@ -12,7 +12,11 @@ const __O = {
|
|
|
12
12
|
};
|
|
13
13
|
const CLS_FEATURED_DOT = "absolute left-2 top-2 rounded-full bg-warning-surface p-[var(--appkit-space-1)] leading-none";
|
|
14
14
|
export function CategoryCard({ category, href, onClick, className = "", }) {
|
|
15
|
-
|
|
15
|
+
// totalProductCount is the rollup (self + all descendant categories),
|
|
16
|
+
// maintained incrementally by onProductWrite — prefer it over the
|
|
17
|
+
// own-node-only productCount so a parent category's card shows the same
|
|
18
|
+
// "N products" a visitor actually sees once they open the page.
|
|
19
|
+
const productCount = category.metrics?.totalProductCount ?? category.metrics?.productCount ?? category.productCount ?? 0;
|
|
16
20
|
const inner = (_jsxs(Stack, { className: "h-full", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-[4/3] w-full ${__O.hidden} flex-shrink-0`, children: [category.display?.coverImage ? (_jsx(MediaImage, { src: category.display.coverImage, alt: category.name, size: "card", className: "transition-transform duration-300 group-hover:scale-105" })) : category.display?.color ? (_jsx(DynamicBgDiv, { color: category.display.color, className: "h-full w-full opacity-80" })) : null, category.display?.icon && (_jsx(Row, { textSize: "4xl", className: "absolute inset-0", align: "center", justify: "center", children: category.display.icon })), category.isFeatured && (_jsx(Span, { size: "xs", className: CLS_FEATURED_DOT, children: "\u2605" }))] }), _jsxs(Stack, { className: `flex-1 ${__P.p3}.5`, children: [_jsx(Text, { color: "inverse", className: `leading-snug text-[var(--appkit-color-text)] dark:`, truncate: 2, size: "sm", weight: "semibold", children: category.name }), category.description && (_jsx(Text, { className: `mt-1 flex-1`, color: "muted", truncate: 2, size: "xs", children: category.description })), _jsxs(Row, { className: "mt-2", align: "center", justify: "between", gap: "sm", children: [_jsxs(Text, { size: "xs", color: "faint", children: [productCount.toLocaleString(), " ", productCount === 1 ? "item" : "items"] }), _jsxs(Span, { layout: "inline-flex", gap: "xs", size: "xs", weight: "medium", border: "default", className: "group-hover:bg-primary group-hover:border-primary group-hover:text-white transition-colors", rounded: "md", padding: "pill-sm-tall", color: "muted", children: ["Browse ", _jsx(ArrowRight, { className: "h-3 w-3" })] })] })] })] }));
|
|
17
21
|
const cardClass = `group relative flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-[var(--appkit-color-surface)] border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] shadow-sm transition hover:shadow-md h-full ${className}`;
|
|
18
22
|
if (href) {
|
|
@@ -12,6 +12,14 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
12
12
|
getCategoriesByTier(tier: number): Promise<CategoryDocument[]>;
|
|
13
13
|
getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
|
|
14
14
|
getChildren(parentId: string): Promise<CategoryDocument[]>;
|
|
15
|
+
/**
|
|
16
|
+
* IDs of every descendant category at any depth (not just direct
|
|
17
|
+
* children) — `parentIds` stores the full ancestor chain, so a single
|
|
18
|
+
* array-contains query on that field already returns the whole subtree
|
|
19
|
+
* with no recursion needed. Used to expand a parent category's product
|
|
20
|
+
* listing/count to include products filed under any of its children.
|
|
21
|
+
*/
|
|
22
|
+
getDescendantIds(categoryId: string): Promise<string[]>;
|
|
15
23
|
getFeaturedCategories(): Promise<CategoryDocument[]>;
|
|
16
24
|
getBrandCategories(limit?: number): Promise<CategoryDocument[]>;
|
|
17
25
|
updateMetrics(categoryId: string, productDelta: number, auctionDelta: number, productId?: string): Promise<void>;
|
|
@@ -161,6 +161,27 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
161
161
|
throw new DatabaseError(`Failed to retrieve children categories: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* IDs of every descendant category at any depth (not just direct
|
|
166
|
+
* children) — `parentIds` stores the full ancestor chain, so a single
|
|
167
|
+
* array-contains query on that field already returns the whole subtree
|
|
168
|
+
* with no recursion needed. Used to expand a parent category's product
|
|
169
|
+
* listing/count to include products filed under any of its children.
|
|
170
|
+
*/
|
|
171
|
+
async getDescendantIds(categoryId) {
|
|
172
|
+
try {
|
|
173
|
+
const snapshot = await this.db
|
|
174
|
+
.collection(this.collection)
|
|
175
|
+
.where("parentIds", "array-contains", categoryId)
|
|
176
|
+
.limit(100)
|
|
177
|
+
.get();
|
|
178
|
+
return snapshot.docs.map((doc) => doc.id);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
void normalizeError(error);
|
|
182
|
+
throw new DatabaseError(`Failed to retrieve descendant categories: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
164
185
|
async getFeaturedCategories() {
|
|
165
186
|
try {
|
|
166
187
|
const snapshot = await this.db
|
|
@@ -14,6 +14,10 @@ export interface CategoryDisplay {
|
|
|
14
14
|
export interface CategoryMetrics {
|
|
15
15
|
productCount: number;
|
|
16
16
|
auctionCount?: number;
|
|
17
|
+
/** Rollup — this category's own productCount plus every descendant category's, maintained incrementally by onProductWrite. */
|
|
18
|
+
totalProductCount?: number;
|
|
19
|
+
/** Rollup — same as totalProductCount but for auctions. */
|
|
20
|
+
totalAuctionCount?: number;
|
|
17
21
|
totalItemCount: number;
|
|
18
22
|
lastUpdated?: string;
|
|
19
23
|
}
|
|
@@ -22,7 +22,7 @@ function CategoryChip({ category }) {
|
|
|
22
22
|
const iconSrc = category.display?.icon;
|
|
23
23
|
const coverImage = category.display?.coverImage;
|
|
24
24
|
const initial = category.name[0]?.toUpperCase() ?? "?";
|
|
25
|
-
const productCount = category.metrics?.productCount ?? 0;
|
|
25
|
+
const productCount = category.metrics?.totalProductCount ?? category.metrics?.productCount ?? 0;
|
|
26
26
|
return (_jsxs(Link, { href: ROUTES.PUBLIC.CATEGORY_DETAIL(category.slug), className: "group flex w-full min-h-[180px] sm:min-h-[220px] flex-col overflow-hidden rounded-xl border border-zinc-200 bg-[var(--appkit-color-surface)] shadow-sm transition-all hover:border-primary-300 hover:shadow-md border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:hover:border-primary-600", children: [coverImage && isImageUrl(coverImage) ? (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, surface: "subtle", children: _jsx(Image, { src: resolveMediaUrl(coverImage), alt: category.name, width: 320, height: 180, className: "h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" }) })) : (_jsx(Div, { surface: "muted", className: "aspect-video w-full" })), _jsxs(Stack, { className: `flex-1 ${__P.p3} text-left`, children: [_jsx(Row, { textWeight: "bold", textSize: "sm", className: "mb-2 h-9 w-9 bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300", align: "center", justify: "center", rounded: "lg", children: iconSrc && isImageUrl(iconSrc) ? (_jsx(Image, { src: resolveMediaUrl(iconSrc), alt: "", width: 24, height: 24, className: "h-6 w-6 rounded object-cover", "aria-hidden": true })) : iconSrc ? (_jsx(Span, { size: "lg", "aria-hidden": "true", className: "leading-none", children: iconSrc })) : (initial) }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", truncate: 2, children: category.name }), _jsxs(Text, { className: "mt-1", color: "muted", size: "xs", children: [productCount.toLocaleString(), " items"] }), _jsx(Text, { className: "mt-auto text-primary dark:text-primary-400 pt-[0.75rem]", size: "xs", weight: "medium", children: "Browse category \u2192" })] })] }));
|
|
27
27
|
}
|
|
28
28
|
const CTA_CLASSES = {
|
|
@@ -112,7 +112,7 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
|
|
|
112
112
|
name: product.title || "product",
|
|
113
113
|
category: (product.categorySlugs?.[0] ?? product.category) || "uncategorized",
|
|
114
114
|
store: product.storeName || "store",
|
|
115
|
-
}), onChange: (url) => update({ mainImage: url }), label: t("formMainImage"), helperText: "Recommended: 800x800px (1:1)" })), isReadonly && product.mainImage && (_jsx(FormField, { name: "mainImage", label: t("formMainImage"), type: "text", value: product.mainImage, onChange: () => { }, disabled: true })), !isReadonly && (_jsx(MediaUploadList, { label: t("formGalleryImages"), value: galleryImages, onChange: (fields) => update({ images: fields.map((f) => f.url) }), onUpload: handleGalleryUpload, accept: "image/*,video/*", maxItems: 5, maxSizeMB: 10, helperText: t("formGalleryImagesHelper"), onAbort: onMediaAbort })), !isReadonly && (_jsx(MediaUploadField, { label: t("formVideo"), value: product.video?.url || "", onChange: (url) => update({
|
|
115
|
+
}), onChange: (url) => update({ mainImage: url }), label: t("formMainImage"), helperText: "Recommended: 800x800px (1:1)" })), isReadonly && product.mainImage && (_jsx(FormField, { name: "mainImage", label: t("formMainImage"), type: "text", value: product.mainImage, onChange: () => { }, disabled: true })), !isReadonly && (_jsx(MediaUploadList, { label: t("formGalleryImages"), value: galleryImages, onChange: (fields) => update({ images: fields.map((f) => f.url) }), onUpload: handleGalleryUpload, accept: "image/*,video/*", maxItems: 5, maxSizeMB: 10, helperText: t("formGalleryImagesHelper"), onAbort: onMediaAbort })), !isReadonly && (_jsx(MediaUploadField, { label: product.listingType === "live" ? `${t("formVideo")} *` : t("formVideo"), value: product.video?.url || "", onChange: (url) => update({
|
|
116
116
|
video: url
|
|
117
117
|
? {
|
|
118
118
|
url,
|
|
@@ -137,7 +137,9 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
|
|
|
137
137
|
thumbnailUrl: media.thumbnailUrl,
|
|
138
138
|
},
|
|
139
139
|
});
|
|
140
|
-
}, onUpload: handleVideoUpload, accept: "video/*", maxSizeMB: 50, helperText:
|
|
140
|
+
}, onUpload: handleVideoUpload, accept: "video/*", maxSizeMB: 50, helperText: product.listingType === "live"
|
|
141
|
+
? "Required for live listings — buyers must see the actual animal/plant moving before purchase."
|
|
142
|
+
: t("formVideoHelper"), onAbort: onMediaAbort })), _jsx(TagInput, { label: t("formTags"), value: product.tags || [], onChange: (tags) => update({ tags }), disabled: isReadonly, placeholder: t("formTagsPlaceholder") }), _jsxs(FormGroup, { columns: 2, children: [_jsx(Checkbox, { label: t("formFeatured"), checked: !!product.featured, onChange: (e) => update({ featured: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formIsPromoted"), checked: !!product.isPromoted, onChange: (e) => update({ isPromoted: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formAllowShipBeforeEmiComplete"), checked: !!product.allowShipBeforeEmiComplete, onChange: (e) => update({ allowShipBeforeEmiComplete: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formAllowOffers"), checked: !!product.allowOffers, onChange: (e) => update({
|
|
141
143
|
allowOffers: e.target.checked,
|
|
142
144
|
minOfferPercent: e.target.checked ? product.minOfferPercent ?? 50 : undefined,
|
|
143
145
|
}), disabled: isReadonly })] }), product.allowOffers && (_jsxs(_Fragment, { children: [_jsx(Alert, { variant: "info", title: t("formAllowOffersHelp"), children: t("formAllowOffersHelp") }), _jsx(FormField, { name: "minOfferPercent", label: t("formMinOfferPercent"), type: "number", value: String(product.minOfferPercent ?? ""), onChange: (value) => update({ minOfferPercent: Number(value) }), disabled: isReadonly, placeholder: "50" })] })), _jsx(Heading, { level: 4, className: "mt-4", children: t("sectionTaxGst") }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "gstRate", label: t("formGstRate"), type: "select",
|
|
@@ -379,8 +379,8 @@ export const productsTesterSeedData = [
|
|
|
379
379
|
withTokens({
|
|
380
380
|
id: "live-tester-sandbox-1",
|
|
381
381
|
slug: "live-tester-sandbox-1",
|
|
382
|
-
title: "Test Live Item —
|
|
383
|
-
description: "Disposable test live-item listing for the tester QA program. Auto-expires in 7 days.",
|
|
382
|
+
title: "Test Live Item — Golden Retriever Puppy",
|
|
383
|
+
description: "Disposable test live-item listing for the tester QA program (verifies the mandatory-video rule, gallery video slide, and watermark on a real animal listing). Auto-expires in 7 days.",
|
|
384
384
|
categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
|
|
385
385
|
categoryNames: COLLECTIBLES_CATEGORY_NAMES,
|
|
386
386
|
brandSlug: "brand-tester-sandbox",
|
|
@@ -392,7 +392,16 @@ export const productsTesterSeedData = [
|
|
|
392
392
|
listingType: "live",
|
|
393
393
|
images: [seedExtMedia("https://picsum.photos/seed/live-image-tester-sandbox-1-20260101/900/900")],
|
|
394
394
|
mainImage: seedExtMedia("https://picsum.photos/seed/live-image-tester-sandbox-1-20260101/900/900"),
|
|
395
|
-
video
|
|
395
|
+
// Real, directly-playable dog video (a raw <video> element, not routed through
|
|
396
|
+
// /api/media/ext which is image-only — Root Cause #27) — Wikimedia Commons,
|
|
397
|
+
// CC-BY-SA 4.0, https://commons.wikimedia.org/wiki/File:Slow_motion_of_running_greyhound.webm.
|
|
398
|
+
// Exercises the mandatory-video-for-live rule + gallery video slide + watermark
|
|
399
|
+
// on a genuine "animal actually moving" clip instead of a broken YouTube watch-page URL.
|
|
400
|
+
video: {
|
|
401
|
+
url: "https://upload.wikimedia.org/wikipedia/commons/4/42/Slow_motion_of_running_greyhound.webm", // audit-seed-external-url-ok: raw <video> src, CC-BY-SA 4.0 (Wikimedia Commons)
|
|
402
|
+
thumbnailUrl: seedExtMedia("https://picsum.photos/seed/live-video-thumb-tester-sandbox-1-20260101/800/450"),
|
|
403
|
+
duration: 14,
|
|
404
|
+
},
|
|
396
405
|
isSold: false,
|
|
397
406
|
stockQuantity: 1,
|
|
398
407
|
availableQuantity: 1,
|
|
@@ -401,9 +410,9 @@ export const productsTesterSeedData = [
|
|
|
401
410
|
isPromoted: false,
|
|
402
411
|
isOnSale: false,
|
|
403
412
|
liveItem: {
|
|
404
|
-
species: "
|
|
413
|
+
species: "Dog (Golden Retriever)",
|
|
405
414
|
ageMonths: 6,
|
|
406
|
-
sex: "
|
|
415
|
+
sex: "male",
|
|
407
416
|
careInfo: "Disposable test care info for the tester QA program.",
|
|
408
417
|
transport: { method: "courier", handlingFee: 100, insuranceIncluded: true },
|
|
409
418
|
jurisdictionAllowed: ["IN-KA", "IN-MH"],
|