@mohasinac/appkit 4.9.5 → 4.10.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/_internal/server/features/auctions/actions.js +3 -2
- package/dist/_internal/server/features/auctions/service.d.ts +4 -3
- package/dist/_internal/server/features/auctions/service.js +6 -6
- package/dist/_internal/server/features/bundles/data.d.ts +9 -0
- package/dist/_internal/server/features/bundles/data.js +14 -0
- package/dist/_internal/server/features/bundles/index.d.ts +1 -1
- package/dist/_internal/server/features/bundles/index.js +1 -1
- package/dist/_internal/server/features/catalogue/og.d.ts +20 -0
- package/dist/_internal/server/features/catalogue/og.js +27 -0
- package/dist/_internal/server/features/grouped/data.d.ts +12 -0
- package/dist/_internal/server/features/grouped/data.js +26 -1
- package/dist/_internal/server/features/grouped/index.d.ts +1 -1
- package/dist/_internal/server/features/grouped/index.js +1 -1
- package/dist/_internal/server/features/products/data.d.ts +23 -0
- package/dist/_internal/server/features/products/data.js +159 -0
- package/dist/_internal/server/features/products/index.d.ts +1 -1
- package/dist/_internal/server/features/products/index.js +1 -1
- package/dist/_internal/shared/features/auctions/config.d.ts +20 -0
- package/dist/_internal/shared/features/auctions/config.js +31 -1
- package/dist/_internal/shared/listing-types/art/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/art/config.js +1 -1
- package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/classified/config.js +1 -1
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/digital-code/config.js +1 -1
- package/dist/_internal/shared/listing-types/live/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/live/config.js +1 -1
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/stickers/config.js +1 -1
- package/dist/client.d.ts +0 -6
- package/dist/client.js +1 -4
- package/dist/constants/api-endpoints.d.ts +3 -0
- package/dist/constants/api-endpoints.js +1 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +12 -4
- package/dist/features/admin/schemas/firestore.d.ts +9 -0
- package/dist/features/admin/schemas/firestore.js +6 -0
- package/dist/features/auctions/actions/bid-actions.js +4 -1
- package/dist/features/auctions/components/AuctionBottomActions.d.ts +2 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +14 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +21 -9
- package/dist/features/auctions/components/LiveMinIncrement.d.ts +18 -0
- package/dist/features/auctions/components/LiveMinIncrement.js +17 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +5 -2
- package/dist/features/auctions/components/PlaceBidFormClient.js +7 -3
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.d.ts +11 -0
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +33 -0
- package/dist/features/catalogue/components/PublicCatalogueView.js +2 -1
- package/dist/features/catalogue/index.d.ts +2 -0
- package/dist/features/catalogue/index.js +1 -0
- package/dist/features/categories/components/BundleDetailView.d.ts +3 -1
- package/dist/features/categories/components/BundleDetailView.js +3 -2
- package/dist/features/classified/components/ClassifiedContactSellerPanel.d.ts +12 -0
- package/dist/features/classified/components/ClassifiedContactSellerPanel.js +49 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +7 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.js +69 -0
- package/dist/features/contact/components/ContactPageView.js +8 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.d.ts +19 -0
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +79 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.d.ts +13 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.js +31 -0
- package/dist/features/grouped/repository/grouped-listings.repository.d.ts +8 -0
- package/dist/features/grouped/repository/grouped-listings.repository.js +16 -0
- package/dist/features/layout/BottomActions.js +4 -3
- package/dist/features/layout/BottomActionsContext.d.ts +10 -0
- package/dist/features/layout/BottomActionsContext.js +5 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +2 -0
- package/dist/features/layout/hooks/useBottomActions.js +4 -1
- package/dist/features/live/components/LiveItemDetailPageView.d.ts +9 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +74 -0
- package/dist/features/media/MediaVideo.js +17 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +9 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +11 -1
- package/dist/features/products/components/ProductDetailPageView.js +11 -116
- package/dist/features/products/components/RelatedItemsSection.d.ts +16 -0
- package/dist/features/products/components/RelatedItemsSection.js +17 -0
- package/dist/features/seller/components/SellerProductShell.js +1 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +83 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +7 -0
- package/dist/seed/bids-seed-data.js +48 -3
- package/dist/seed/grouped-listings-seed-data.js +167 -0
- package/dist/seed/payouts-seed-data.js +4 -4
- package/dist/seed/products-art-seed-data.js +188 -5
- package/dist/seed/products-auctions-seed-data.js +215 -6
- package/dist/seed/products-classifieds-seed-data.js +255 -8
- package/dist/seed/products-digital-codes-seed-data.js +258 -19
- package/dist/seed/products-live-items-seed-data.js +143 -10
- package/dist/seed/products-preorders-seed-data.js +213 -8
- package/dist/seed/products-prize-draws-seed-data.js +198 -0
- package/dist/seed/products-standard-seed-data.js +141 -0
- package/dist/seed/products-stickers-seed-data.js +178 -5
- package/dist/seed/site-settings-seed-data.js +10 -29
- package/dist/seed/store-extensions-seed-data.js +1 -1
- package/dist/server-entry.d.ts +4 -1
- package/dist/server-entry.js +4 -1
- package/dist/server.d.ts +4 -2
- package/dist/server.js +4 -2
- package/dist/styles.css +29 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageLightbox.js +37 -3
- package/dist/ui/components/ImageLightbox.style.css +28 -0
- package/dist/utils/media-url.d.ts +9 -0
- package/dist/utils/media-url.js +39 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
import { wrapAction } from "@mohasinac/appkit/server";
|
|
3
|
-
import { bidRepository, productRepository, userRepository } from "../../../../repositories";
|
|
3
|
+
import { bidRepository, productRepository, userRepository, siteSettingsRepository } from "../../../../repositories";
|
|
4
4
|
import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
|
|
5
5
|
import { placeBidSchema } from "../../../shared/features/auctions/schema";
|
|
6
6
|
import { assertAuctionActive, assertBidAmount, assertNotAuctionOwner, shouldAutoExtend, computeExtendedEndDate, } from "./service";
|
|
@@ -14,7 +14,8 @@ export async function placeBidAction(input) {
|
|
|
14
14
|
const { auctionId, amount } = parsed.data;
|
|
15
15
|
const product = await assertAuctionActive(auctionId);
|
|
16
16
|
assertNotAuctionOwner(product, user.uid);
|
|
17
|
-
|
|
17
|
+
const settings = await siteSettingsRepository.getSingleton();
|
|
18
|
+
assertBidAmount(product, amount, settings.auctionConfig?.bidIncrementTiers ?? []);
|
|
18
19
|
const profile = await userRepository.findById(user.uid).catch(() => null);
|
|
19
20
|
const userName = profile?.displayName ?? user.name ?? "Anonymous";
|
|
20
21
|
// Mark all current bids for this auction as outbid, then set new winning bid
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { type BidIncrementTier } from "../../../shared/features/auctions/config";
|
|
1
2
|
import type { ProductDocument } from "../../../shared/features/products/types";
|
|
2
3
|
/** Assert an auction exists, is published, and is still active. Returns the product. */
|
|
3
4
|
export declare function assertAuctionActive(auctionId: string): Promise<ProductDocument>;
|
|
4
|
-
/** Compute the minimum valid bid given the current state. */
|
|
5
|
-
export declare function computeMinBid(product: ProductDocument): number;
|
|
5
|
+
/** Compute the minimum valid bid given the current state and the admin's tier table. */
|
|
6
|
+
export declare function computeMinBid(product: ProductDocument, tiers: BidIncrementTier[]): number;
|
|
6
7
|
/** Assert a bid amount is valid against the current auction state. */
|
|
7
|
-
export declare function assertBidAmount(product: ProductDocument, amount: number): void;
|
|
8
|
+
export declare function assertBidAmount(product: ProductDocument, amount: number, tiers: BidIncrementTier[]): void;
|
|
8
9
|
/** Assert the bidder is not the auction owner. */
|
|
9
10
|
export declare function assertNotAuctionOwner(product: ProductDocument, bidderId: string): void;
|
|
10
11
|
/** Return true if the bid was placed within the sniping window and the auction should auto-extend. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { productRepository } from "../../../../repositories";
|
|
2
2
|
import { isAuctionListing } from "../../../../features/products/utils/listing-type";
|
|
3
3
|
import { AuctionNotFoundError, AuctionEndedError, BidTooLowError, BidOnOwnAuctionError, } from "../../../shared/features/auctions/errors";
|
|
4
|
-
import {
|
|
4
|
+
import { AUCTION_SNIPING_WINDOW_SECONDS, AUCTION_DEFAULT_EXTENSION_MINUTES, resolveMinBidIncrement, } from "../../../shared/features/auctions/config";
|
|
5
5
|
/** Assert an auction exists, is published, and is still active. Returns the product. */
|
|
6
6
|
export async function assertAuctionActive(auctionId) {
|
|
7
7
|
const product = await productRepository.findByIdOrSlug(auctionId).catch(() => null);
|
|
@@ -16,15 +16,15 @@ export async function assertAuctionActive(auctionId) {
|
|
|
16
16
|
throw new AuctionEndedError(auctionId);
|
|
17
17
|
return product;
|
|
18
18
|
}
|
|
19
|
-
/** Compute the minimum valid bid given the current state. */
|
|
20
|
-
export function computeMinBid(product) {
|
|
19
|
+
/** Compute the minimum valid bid given the current state and the admin's tier table. */
|
|
20
|
+
export function computeMinBid(product, tiers) {
|
|
21
21
|
const current = product.currentBid ?? product.startingBid ?? 0;
|
|
22
|
-
const increment = product.minBidIncrement
|
|
22
|
+
const increment = resolveMinBidIncrement(current, tiers, product.minBidIncrement);
|
|
23
23
|
return current + increment;
|
|
24
24
|
}
|
|
25
25
|
/** Assert a bid amount is valid against the current auction state. */
|
|
26
|
-
export function assertBidAmount(product, amount) {
|
|
27
|
-
const minBid = computeMinBid(product);
|
|
26
|
+
export function assertBidAmount(product, amount, tiers) {
|
|
27
|
+
const minBid = computeMinBid(product, tiers);
|
|
28
28
|
if (amount < minBid)
|
|
29
29
|
throw new BidTooLowError(minBid);
|
|
30
30
|
}
|
|
@@ -45,3 +45,12 @@ export declare function resolveBundleOriginalTotal(productIds: string[]): Promis
|
|
|
45
45
|
* `limit` (default 8) and filtered to `isActive: true` server-side.
|
|
46
46
|
*/
|
|
47
47
|
export declare const listFeaturedBundles: (limit?: number, _opts?: BundleDataOptions) => Promise<CategoryDocument[]>;
|
|
48
|
+
/**
|
|
49
|
+
* "Related Bundles" for the bundle detail page — every other active bundle,
|
|
50
|
+
* excluding the current one. Bundles are a CategoryDocument, not a
|
|
51
|
+
* ProductDocument, so this mirrors BrandDetailPageView's "Related Brands"
|
|
52
|
+
* pattern (all active siblings of the same categoryType) rather than the
|
|
53
|
+
* product-based 4-signal computeRelatedItems() used elsewhere — bundle
|
|
54
|
+
* detail pages had no related section at all before this.
|
|
55
|
+
*/
|
|
56
|
+
export declare const getRelatedBundles: (bundle: Pick<CategoryDocument, "id">, limit?: number) => Promise<CategoryDocument[]>;
|
|
@@ -79,3 +79,17 @@ export const listFeaturedBundles = cache(async (limit = 8, _opts) => {
|
|
|
79
79
|
.catch(() => []);
|
|
80
80
|
return all;
|
|
81
81
|
});
|
|
82
|
+
/**
|
|
83
|
+
* "Related Bundles" for the bundle detail page — every other active bundle,
|
|
84
|
+
* excluding the current one. Bundles are a CategoryDocument, not a
|
|
85
|
+
* ProductDocument, so this mirrors BrandDetailPageView's "Related Brands"
|
|
86
|
+
* pattern (all active siblings of the same categoryType) rather than the
|
|
87
|
+
* product-based 4-signal computeRelatedItems() used elsewhere — bundle
|
|
88
|
+
* detail pages had no related section at all before this.
|
|
89
|
+
*/
|
|
90
|
+
export const getRelatedBundles = cache(async (bundle, limit = 8) => {
|
|
91
|
+
const all = await categoriesRepository
|
|
92
|
+
.listByType("bundle", { activeOnly: true, limit: limit + 1 })
|
|
93
|
+
.catch(() => []);
|
|
94
|
+
return all.filter((b) => b.id !== bundle.id).slice(0, limit);
|
|
95
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { getBundleForDetail, listBundleMembers, listFeaturedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, type BundleDataOptions, } from "./data";
|
|
1
|
+
export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, type BundleDataOptions, } from "./data";
|
|
2
2
|
export { buildBundleMetadata, type BundleMetadataOptions } from "./metadata";
|
|
3
3
|
export { renderBundleOg, renderBundleOgImage, type BundleOgData, } from "./og";
|
|
4
4
|
export { addBundleToCartAction } from "./actions";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { getBundleForDetail, listBundleMembers, listFeaturedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, } from "./data";
|
|
1
|
+
export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, } from "./data";
|
|
2
2
|
export { buildBundleMetadata } from "./metadata";
|
|
3
3
|
// S-SBUNI-4 2026-05-13 — bundle OG renderer.
|
|
4
4
|
export { renderBundleOg, renderBundleOgImage, } from "./og";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
export interface CatalogueItemOgData {
|
|
3
|
+
title: string;
|
|
4
|
+
ownerName?: string | null;
|
|
5
|
+
priceLabel?: string | null;
|
|
6
|
+
imageUrl?: string | null;
|
|
7
|
+
}
|
|
8
|
+
interface CatalogueItemDocLike {
|
|
9
|
+
title?: string | null;
|
|
10
|
+
price?: number | null;
|
|
11
|
+
mainImage?: string | null;
|
|
12
|
+
images?: (string | null | undefined)[] | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function renderCatalogueItemOg(doc: CatalogueItemDocLike | null | undefined, opts: {
|
|
15
|
+
siteName: string;
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
ownerName?: string | null;
|
|
18
|
+
}): ReactElement;
|
|
19
|
+
export declare function renderCatalogueItemOgImage(data: CatalogueItemOgData, siteName: string): ReactElement;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { renderOgLayout } from "../seo/og-layout";
|
|
2
|
+
import { resolveOgImageUrl } from "../seo/og";
|
|
3
|
+
export function renderCatalogueItemOg(doc, opts) {
|
|
4
|
+
const priceLabel = doc?.price != null && doc.price > 0
|
|
5
|
+
? new Intl.NumberFormat("en-IN", {
|
|
6
|
+
style: "currency",
|
|
7
|
+
currency: "INR",
|
|
8
|
+
maximumFractionDigits: 0,
|
|
9
|
+
}).format(doc.price)
|
|
10
|
+
: null;
|
|
11
|
+
return renderCatalogueItemOgImage({
|
|
12
|
+
title: doc?.title ?? "Catalogue Item",
|
|
13
|
+
ownerName: opts.ownerName,
|
|
14
|
+
priceLabel,
|
|
15
|
+
imageUrl: resolveOgImageUrl(doc?.mainImage || doc?.images?.[0] || null, opts.baseUrl),
|
|
16
|
+
}, opts.siteName);
|
|
17
|
+
}
|
|
18
|
+
export function renderCatalogueItemOgImage(data, siteName) {
|
|
19
|
+
return renderOgLayout({
|
|
20
|
+
title: data.title,
|
|
21
|
+
subtitle: data.ownerName ? `Owned by ${data.ownerName}` : undefined,
|
|
22
|
+
imageUrl: data.imageUrl,
|
|
23
|
+
siteName: `${siteName} · Catalogue`,
|
|
24
|
+
accentSlot: data.priceLabel,
|
|
25
|
+
theme: { accentColor: "#a78bfa" },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -18,6 +18,18 @@ export interface ListGroupedListingsParams {
|
|
|
18
18
|
export declare function listGroupedListings(params?: ListGroupedListingsParams): Promise<GroupedListingDocument[]>;
|
|
19
19
|
/** Featured grouped listings for homepage strips. */
|
|
20
20
|
export declare const listFeaturedGroupedListings: (limit?: number) => Promise<GroupedListingDocument[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Groups a product belongs to that are eligible to render publicly —
|
|
23
|
+
* powers the "You might also like" / "Same character" / etc. carousel
|
|
24
|
+
* on product-based listing detail pages. Deduped per request.
|
|
25
|
+
*/
|
|
26
|
+
export declare const getGroupsForProduct: (productId: string) => Promise<GroupedListingDocument[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Groups a product belongs to, each hydrated with its OTHER member products
|
|
29
|
+
* (the current product is excluded from its own group's item list) — ready
|
|
30
|
+
* to render directly as a public GroupedListingsCarousel section.
|
|
31
|
+
*/
|
|
32
|
+
export declare const getGroupsWithItemsForProduct: (productId: string) => Promise<GroupedListingWithItems[]>;
|
|
21
33
|
export interface SitemapGroupedListing {
|
|
22
34
|
slug: string;
|
|
23
35
|
updatedAt: Date;
|
|
@@ -3,7 +3,7 @@ import { getAdminDb } from "../../../../providers/db-firebase";
|
|
|
3
3
|
import { normalizeError } from "../../../../errors/normalize";
|
|
4
4
|
import { serverLogger } from "../../../../monitoring/server-logger";
|
|
5
5
|
import { GROUPED_LISTINGS_COLLECTION, } from "../../../../features/grouped/schemas/firestore";
|
|
6
|
-
import { productRepository } from "../../../../repositories";
|
|
6
|
+
import { productRepository, groupedListingsRepository } from "../../../../repositories";
|
|
7
7
|
import { PRODUCT_FIELDS, CATEGORY_FIELDS } from "../../../../constants/field-names";
|
|
8
8
|
import { GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
9
9
|
function mapDoc(doc) {
|
|
@@ -107,6 +107,31 @@ export async function listGroupedListings(params = {}) {
|
|
|
107
107
|
export const listFeaturedGroupedListings = cache(async (limit = GROUPED_LISTINGS_FEATURED_LIMIT) => {
|
|
108
108
|
return listGroupedListings({ featuredOnly: true, limit });
|
|
109
109
|
});
|
|
110
|
+
/**
|
|
111
|
+
* Groups a product belongs to that are eligible to render publicly —
|
|
112
|
+
* powers the "You might also like" / "Same character" / etc. carousel
|
|
113
|
+
* on product-based listing detail pages. Deduped per request.
|
|
114
|
+
*/
|
|
115
|
+
export const getGroupsForProduct = cache(async (productId) => {
|
|
116
|
+
return groupedListingsRepository.findByProductId(productId).catch((err) => {
|
|
117
|
+
void normalizeError(err);
|
|
118
|
+
serverLogger.warn("grouped-data: getGroupsForProduct failed — returning empty", { productId, error: err instanceof Error ? err.message : String(err) });
|
|
119
|
+
return [];
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* Groups a product belongs to, each hydrated with its OTHER member products
|
|
124
|
+
* (the current product is excluded from its own group's item list) — ready
|
|
125
|
+
* to render directly as a public GroupedListingsCarousel section.
|
|
126
|
+
*/
|
|
127
|
+
export const getGroupsWithItemsForProduct = cache(async (productId) => {
|
|
128
|
+
const groups = await getGroupsForProduct(productId);
|
|
129
|
+
return Promise.all(groups.map(async (group) => {
|
|
130
|
+
const otherIds = group.productIds.filter((id) => id !== productId);
|
|
131
|
+
const items = await Promise.all(otherIds.map((id) => productRepository.findByIdOrSlug(id).catch(() => null)));
|
|
132
|
+
return { ...group, items: items.filter((p) => p !== null) };
|
|
133
|
+
}));
|
|
134
|
+
});
|
|
110
135
|
export async function listSitemapGroupedListings() {
|
|
111
136
|
try {
|
|
112
137
|
const db = getAdminDb();
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, type GroupedListingWithItems, type ListGroupedListingsParams, type SitemapGroupedListing, } from "./data";
|
|
1
|
+
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, type GroupedListingWithItems, type ListGroupedListingsParams, type SitemapGroupedListing, } from "./data";
|
|
2
2
|
export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, } from "./data";
|
|
1
|
+
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, } from "./data";
|
|
2
2
|
export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
2
2
|
import type { ListingType } from "../../../../features/products/types";
|
|
3
|
+
import type { ProductItem } from "../../../../features/products/types";
|
|
4
|
+
import type { FirestoreDocument } from "../../../../schemas/types";
|
|
5
|
+
import type { Review } from "../../../../features/reviews/types";
|
|
3
6
|
/** Fetch a single product by slug or id, deduped per request. */
|
|
4
7
|
export declare const getProductForDetail: (slugOrId: string) => Promise<ProductDocument | null>;
|
|
5
8
|
/** Fetch the first page of approved reviews for a product, deduped per request. */
|
|
6
9
|
export declare const getReviewsForProduct: (productId: string) => Promise<unknown[]>;
|
|
10
|
+
/** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
|
|
11
|
+
export declare function toReview(doc: FirestoreDocument): Review;
|
|
12
|
+
/** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
|
|
13
|
+
export declare const getReviewItemsForProduct: (productId: string) => Promise<Review[]>;
|
|
7
14
|
/** Minimal product fields needed for sitemap generation. */
|
|
8
15
|
export interface SitemapProduct {
|
|
9
16
|
slugOrId: string;
|
|
@@ -13,3 +20,19 @@ export interface SitemapProduct {
|
|
|
13
20
|
}
|
|
14
21
|
/** List all published products for sitemap generation. */
|
|
15
22
|
export declare function listSitemapProducts(): Promise<SitemapProduct[]>;
|
|
23
|
+
export interface RelatedItemsResult {
|
|
24
|
+
relatedItems: ProductItem[];
|
|
25
|
+
relatedByBrand: ProductItem[];
|
|
26
|
+
relatedByTags: ProductItem[];
|
|
27
|
+
relatedByStore: ProductItem[];
|
|
28
|
+
}
|
|
29
|
+
/** Firestore doc → card-grid shape. Shared by every related/similar-items carousel. */
|
|
30
|
+
export declare function toProductItem(doc: FirestoreDocument): ProductItem;
|
|
31
|
+
/**
|
|
32
|
+
* Same 4-signal related-items computation used across every listing-type
|
|
33
|
+
* detail page (category / brand / tag-overlap / same-store), each excluding
|
|
34
|
+
* invalid states for that item's own listing type. Extracted from
|
|
35
|
+
* ProductDetailPageView so pre-order/prize-draw/classified/digital-code/live
|
|
36
|
+
* pages can reuse it instead of having no related section at all.
|
|
37
|
+
*/
|
|
38
|
+
export declare const computeRelatedItems: (product: ProductDocument) => Promise<RelatedItemsResult>;
|
|
@@ -7,6 +7,7 @@ import { serverLogger } from "../../../../monitoring/server-logger";
|
|
|
7
7
|
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
|
|
8
8
|
import { PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
9
9
|
import { PRODUCT_FIELDS } from "../../../../constants/field-names";
|
|
10
|
+
import { SIEVE_OP, sieveAnd, sieveFilter, sortBy } from "../../../../utils/sieve-builder";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Request-scoped cache — each function is deduplicated per RSC render tree.
|
|
12
13
|
// Both generateMetadata() and the page component get the same promise.
|
|
@@ -23,6 +24,38 @@ export const getProductForDetail = cache(async (slugOrId) => {
|
|
|
23
24
|
export const getReviewsForProduct = cache(async (productId) => {
|
|
24
25
|
return reviewRepository.findApprovedByProduct(productId).catch(() => []);
|
|
25
26
|
});
|
|
27
|
+
/** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
|
|
28
|
+
export function toReview(doc) {
|
|
29
|
+
const images = Array.isArray(doc.images) ? doc.images : undefined;
|
|
30
|
+
const createdAt = doc.createdAt instanceof Date
|
|
31
|
+
? doc.createdAt.toISOString()
|
|
32
|
+
: typeof doc.createdAt === "string"
|
|
33
|
+
? doc.createdAt
|
|
34
|
+
: undefined;
|
|
35
|
+
const rawRating = typeof doc.rating === "number" ? doc.rating : 3;
|
|
36
|
+
const rating = Math.min(5, Math.max(1, Math.round(rawRating)));
|
|
37
|
+
return {
|
|
38
|
+
id: String(doc.id ?? ""),
|
|
39
|
+
productId: String(doc.productId ?? ""),
|
|
40
|
+
userId: String(doc.userId ?? ""),
|
|
41
|
+
userName: String(doc.userName ?? "Anonymous"),
|
|
42
|
+
userAvatar: typeof doc.userAvatar === "string" ? doc.userAvatar : undefined,
|
|
43
|
+
rating,
|
|
44
|
+
title: typeof doc.title === "string" ? doc.title : undefined,
|
|
45
|
+
comment: typeof doc.comment === "string" ? doc.comment : undefined,
|
|
46
|
+
images,
|
|
47
|
+
status: doc.status ?? "approved",
|
|
48
|
+
helpfulCount: typeof doc.helpfulCount === "number" ? doc.helpfulCount : undefined,
|
|
49
|
+
verified: doc.verified === true,
|
|
50
|
+
featured: doc.featured === true,
|
|
51
|
+
createdAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
|
|
55
|
+
export const getReviewItemsForProduct = cache(async (productId) => {
|
|
56
|
+
const docs = await reviewRepository.findApprovedByProduct(productId).catch(() => []);
|
|
57
|
+
return docs.map(toReview);
|
|
58
|
+
});
|
|
26
59
|
/** List all published products for sitemap generation. */
|
|
27
60
|
export async function listSitemapProducts() {
|
|
28
61
|
try {
|
|
@@ -54,3 +87,129 @@ export async function listSitemapProducts() {
|
|
|
54
87
|
return [];
|
|
55
88
|
}
|
|
56
89
|
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Related items — same 4-signal computation shared by every listing-type
|
|
92
|
+
// detail page (category / brand / tag-overlap / same-store).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const RELATED_QUERY_PAGE_SIZE = 9;
|
|
95
|
+
const RELATED_DISPLAY_LIMIT = 8;
|
|
96
|
+
/** Firestore doc → card-grid shape. Shared by every related/similar-items carousel. */
|
|
97
|
+
export function toProductItem(doc) {
|
|
98
|
+
return {
|
|
99
|
+
id: String(doc.id ?? ""),
|
|
100
|
+
title: String(doc.title ?? doc.name ?? ""),
|
|
101
|
+
price: typeof doc.price === "number" ? doc.price : 0,
|
|
102
|
+
originalPrice: typeof doc.originalPrice === "number" ? doc.originalPrice : undefined,
|
|
103
|
+
mainImage: Array.isArray(doc.images)
|
|
104
|
+
? doc.images[0]
|
|
105
|
+
: typeof doc.mainImage === "string"
|
|
106
|
+
? doc.mainImage
|
|
107
|
+
: undefined,
|
|
108
|
+
status: doc.status ?? "published",
|
|
109
|
+
slug: typeof doc.slug === "string" ? doc.slug : undefined,
|
|
110
|
+
storeName: typeof doc.storeName === "string" ? doc.storeName : undefined,
|
|
111
|
+
rating: typeof doc.rating === "number" ? doc.rating : undefined,
|
|
112
|
+
reviewCount: typeof doc.reviewCount === "number" ? doc.reviewCount : undefined,
|
|
113
|
+
// Without this, every related-item card silently linked to the standard
|
|
114
|
+
// PDP regardless of its real type (pluginFor() defaults to "standard"
|
|
115
|
+
// when listingType is missing) — same bug class as the Phase 1 routing
|
|
116
|
+
// fix, one hop further downstream in the related-items card link path.
|
|
117
|
+
listingType: doc.listingType ?? undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Excludes the current product plus any listing-type-specific "invalid to
|
|
122
|
+
* surface as related" state: sold/archived/out-of-stock/draft, ended
|
|
123
|
+
* auctions, closed prize-draws, sold-out pre-orders, depleted digital-code
|
|
124
|
+
* pools.
|
|
125
|
+
*/
|
|
126
|
+
function isValidRelatedItem(r, currentProductId, now) {
|
|
127
|
+
if (r.id === currentProductId)
|
|
128
|
+
return false;
|
|
129
|
+
const s = r.status;
|
|
130
|
+
if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
|
|
131
|
+
return false;
|
|
132
|
+
if (r.isSold === true)
|
|
133
|
+
return false;
|
|
134
|
+
if (r.availableQuantity === 0)
|
|
135
|
+
return false;
|
|
136
|
+
if (r.listingType === "auction" && r.auctionEndDate) {
|
|
137
|
+
const end = r.auctionEndDate;
|
|
138
|
+
const endDate = typeof end.toDate === "function"
|
|
139
|
+
? end.toDate()
|
|
140
|
+
: end instanceof Date
|
|
141
|
+
? end
|
|
142
|
+
: new Date(String(end));
|
|
143
|
+
if (endDate <= now)
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (r.listingType === "prize-draw" && r.prizeRevealStatus === "closed")
|
|
147
|
+
return false;
|
|
148
|
+
if (r.listingType === "pre-order" &&
|
|
149
|
+
typeof r.preOrderMaxQuantity === "number" &&
|
|
150
|
+
typeof r.preOrderCurrentCount === "number" &&
|
|
151
|
+
r.preOrderCurrentCount >= r.preOrderMaxQuantity) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (r.listingType === "digital-code" && r.codesAvailable === 0)
|
|
155
|
+
return false;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
function toRelatedItems(docs, currentProductId) {
|
|
159
|
+
const now = new Date();
|
|
160
|
+
return docs
|
|
161
|
+
.filter((r) => isValidRelatedItem(r, currentProductId, now))
|
|
162
|
+
.slice(0, RELATED_DISPLAY_LIMIT)
|
|
163
|
+
.map(toProductItem);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Same 4-signal related-items computation used across every listing-type
|
|
167
|
+
* detail page (category / brand / tag-overlap / same-store), each excluding
|
|
168
|
+
* invalid states for that item's own listing type. Extracted from
|
|
169
|
+
* ProductDetailPageView so pre-order/prize-draw/classified/digital-code/live
|
|
170
|
+
* pages can reuse it instead of having no related section at all.
|
|
171
|
+
*/
|
|
172
|
+
export const computeRelatedItems = cache(async (product) => {
|
|
173
|
+
const p = product;
|
|
174
|
+
const primaryCategorySlug = Array.isArray(p.categorySlugs) ? p.categorySlugs[0] : undefined;
|
|
175
|
+
const brand = typeof p.brand === "string" ? p.brand : undefined;
|
|
176
|
+
const tags = Array.isArray(p.tags) ? p.tags : [];
|
|
177
|
+
const storeId = typeof p.storeId === "string" ? p.storeId : undefined;
|
|
178
|
+
const currentProductId = product.id;
|
|
179
|
+
const [sameCategoryDocs, sameBrandDocs, tagOverlapDocs, sameStoreDocs] = await Promise.all([
|
|
180
|
+
primaryCategorySlug
|
|
181
|
+
? productRepository.findByCategory(primaryCategorySlug).catch(() => [])
|
|
182
|
+
: Promise.resolve([]),
|
|
183
|
+
brand
|
|
184
|
+
? productRepository
|
|
185
|
+
.list({
|
|
186
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("brand", SIEVE_OP.EQ, brand)),
|
|
187
|
+
sorts: sortBy("createdAt", "DESC"),
|
|
188
|
+
page: 1,
|
|
189
|
+
pageSize: RELATED_QUERY_PAGE_SIZE,
|
|
190
|
+
})
|
|
191
|
+
.then((r) => r.items)
|
|
192
|
+
.catch(() => [])
|
|
193
|
+
: Promise.resolve([]),
|
|
194
|
+
tags.length > 0
|
|
195
|
+
? productRepository.findByTagsOverlap(tags, RELATED_QUERY_PAGE_SIZE).catch(() => [])
|
|
196
|
+
: Promise.resolve([]),
|
|
197
|
+
storeId
|
|
198
|
+
? productRepository
|
|
199
|
+
.list({
|
|
200
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("storeId", SIEVE_OP.EQ, storeId)),
|
|
201
|
+
sorts: sortBy("createdAt", "DESC"),
|
|
202
|
+
page: 1,
|
|
203
|
+
pageSize: RELATED_QUERY_PAGE_SIZE,
|
|
204
|
+
})
|
|
205
|
+
.then((r) => r.items)
|
|
206
|
+
.catch(() => [])
|
|
207
|
+
: Promise.resolve([]),
|
|
208
|
+
]);
|
|
209
|
+
return {
|
|
210
|
+
relatedItems: toRelatedItems(sameCategoryDocs, currentProductId),
|
|
211
|
+
relatedByBrand: toRelatedItems(sameBrandDocs, currentProductId),
|
|
212
|
+
relatedByTags: toRelatedItems(tagOverlapDocs, currentProductId),
|
|
213
|
+
relatedByStore: toRelatedItems(sameStoreDocs, currentProductId),
|
|
214
|
+
};
|
|
215
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, type SitemapProduct, } from "./data";
|
|
1
|
+
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, type SitemapProduct, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, type RelatedItemsResult, } from "./data";
|
|
2
2
|
export { assertProductOwnership, assertStatusTransition, assertInStock, effectivePrice, isAvailableForPurchase, } from "./service";
|
|
3
3
|
export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./actions";
|
|
4
4
|
export { PRODUCTS_PAGE_SIZE, PRODUCTS_FEATURED_LIMIT, PRODUCTS_RELATED_LIMIT, PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, } from "./data";
|
|
1
|
+
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, } from "./data";
|
|
2
2
|
export { assertProductOwnership, assertStatusTransition, assertInStock, effectivePrice, isAvailableForPurchase, } from "./service";
|
|
3
3
|
export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./actions";
|
|
4
4
|
export { PRODUCTS_PAGE_SIZE, PRODUCTS_FEATURED_LIMIT, PRODUCTS_RELATED_LIMIT, PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
@@ -4,3 +4,23 @@ export declare const AUCTIONS_SITEMAP_LIMIT = 5000;
|
|
|
4
4
|
export declare const AUCTION_DEFAULT_EXTENSION_MINUTES = 5;
|
|
5
5
|
export declare const AUCTION_MIN_BID_INCREMENT = 1;
|
|
6
6
|
export declare const AUCTION_SNIPING_WINDOW_SECONDS = 300;
|
|
7
|
+
export interface BidIncrementTier {
|
|
8
|
+
/** Upper bound (inclusive) of this band's current-bid range. `null` = open-ended ("and above"), must be the last tier. */
|
|
9
|
+
upTo: number | null;
|
|
10
|
+
increment: number;
|
|
11
|
+
}
|
|
12
|
+
/** Seed/fallback tier table — single source of truth reused by both DEFAULT_SITE_SETTINGS_DATA and the admin editor's initial state. */
|
|
13
|
+
export declare const DEFAULT_AUCTION_BID_INCREMENT_TIERS: BidIncrementTier[];
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the tiered minimum bid increment for a given current bid amount.
|
|
16
|
+
* Tier lookup is inclusive of each band's upper bound — e.g. with the
|
|
17
|
+
* default tiers, a current bid of exactly 1000 resolves to the 100
|
|
18
|
+
* increment (the 100-1000 tier), not the 200 increment of the next band.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveTieredBidIncrement(currentBidAmount: number, tiers: BidIncrementTier[]): number;
|
|
21
|
+
/**
|
|
22
|
+
* Effective minimum bid increment = max(admin tier floor, per-listing
|
|
23
|
+
* override). A seller's per-listing "Minimum Bid Increment" can require
|
|
24
|
+
* MORE than the platform tier, but can never undercut it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveMinBidIncrement(currentBidAmount: number, tiers: BidIncrementTier[], override?: number | null): number;
|
|
@@ -2,5 +2,35 @@ export const AUCTIONS_PAGE_SIZE = 20;
|
|
|
2
2
|
export const AUCTIONS_ACTIVE_LIMIT = 50;
|
|
3
3
|
export const AUCTIONS_SITEMAP_LIMIT = 5000;
|
|
4
4
|
export const AUCTION_DEFAULT_EXTENSION_MINUTES = 5;
|
|
5
|
-
export const AUCTION_MIN_BID_INCREMENT = 1; // ₹1
|
|
5
|
+
export const AUCTION_MIN_BID_INCREMENT = 1; // ₹1 — last-resort fallback when bidIncrementTiers is empty/missing
|
|
6
6
|
export const AUCTION_SNIPING_WINDOW_SECONDS = 300; // 5 minutes — triggers auto-extend
|
|
7
|
+
/** Seed/fallback tier table — single source of truth reused by both DEFAULT_SITE_SETTINGS_DATA and the admin editor's initial state. */
|
|
8
|
+
export const DEFAULT_AUCTION_BID_INCREMENT_TIERS = [
|
|
9
|
+
{ upTo: 100, increment: 10 },
|
|
10
|
+
{ upTo: 1000, increment: 100 },
|
|
11
|
+
{ upTo: 5000, increment: 200 },
|
|
12
|
+
{ upTo: 10000, increment: 500 },
|
|
13
|
+
{ upTo: null, increment: 1000 },
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the tiered minimum bid increment for a given current bid amount.
|
|
17
|
+
* Tier lookup is inclusive of each band's upper bound — e.g. with the
|
|
18
|
+
* default tiers, a current bid of exactly 1000 resolves to the 100
|
|
19
|
+
* increment (the 100-1000 tier), not the 200 increment of the next band.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveTieredBidIncrement(currentBidAmount, tiers) {
|
|
22
|
+
for (const tier of tiers) {
|
|
23
|
+
if (tier.upTo === null || currentBidAmount <= tier.upTo)
|
|
24
|
+
return tier.increment;
|
|
25
|
+
}
|
|
26
|
+
return tiers[tiers.length - 1]?.increment ?? AUCTION_MIN_BID_INCREMENT;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Effective minimum bid increment = max(admin tier floor, per-listing
|
|
30
|
+
* override). A seller's per-listing "Minimum Bid Increment" can require
|
|
31
|
+
* MORE than the platform tier, but can never undercut it.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveMinBidIncrement(currentBidAmount, tiers, override) {
|
|
34
|
+
const tierValue = resolveTieredBidIncrement(currentBidAmount, tiers);
|
|
35
|
+
return typeof override === "number" && override > tierValue ? override : tierValue;
|
|
36
|
+
}
|
|
@@ -7,7 +7,7 @@ export const config = {
|
|
|
7
7
|
slugPrefix: "art-",
|
|
8
8
|
cartLine: "single-product",
|
|
9
9
|
detailRoute: (idOrSlug) => String(ROUTES.PUBLIC.PRODUCT_DETAIL(idOrSlug)),
|
|
10
|
-
badge:
|
|
10
|
+
badge: { label: "Art Print", className: "bg-primary-700 text-white" },
|
|
11
11
|
priceLabel: "Price (₹)",
|
|
12
12
|
typeLabel: "Art Print",
|
|
13
13
|
showsStockQuantity: true,
|
|
@@ -7,7 +7,7 @@ export const config = {
|
|
|
7
7
|
slugPrefix: "classified-",
|
|
8
8
|
cartLine: "blocked",
|
|
9
9
|
detailRoute: (idOrSlug) => String(ROUTES.PUBLIC.CLASSIFIED_DETAIL(idOrSlug)),
|
|
10
|
-
badge:
|
|
10
|
+
badge: { label: "Classified", className: "bg-secondary text-white" },
|
|
11
11
|
priceLabel: "Asking Price (₹)",
|
|
12
12
|
typeLabel: "Classified",
|
|
13
13
|
showsStockQuantity: true,
|
|
@@ -7,7 +7,7 @@ export const config = {
|
|
|
7
7
|
slugPrefix: "digitalcode-",
|
|
8
8
|
cartLine: "single-product",
|
|
9
9
|
detailRoute: (idOrSlug) => String(ROUTES.PUBLIC.DIGITAL_CODE_DETAIL(idOrSlug)),
|
|
10
|
-
badge:
|
|
10
|
+
badge: { label: "Digital Code", className: "bg-success-surface text-success" },
|
|
11
11
|
priceLabel: "Price per Code (₹)",
|
|
12
12
|
typeLabel: "Digital Code",
|
|
13
13
|
showsStockQuantity: false,
|
|
@@ -7,7 +7,7 @@ export const config = {
|
|
|
7
7
|
slugPrefix: "live-",
|
|
8
8
|
cartLine: "single-product",
|
|
9
9
|
detailRoute: (idOrSlug) => String(ROUTES.PUBLIC.LIVE_DETAIL(idOrSlug)),
|
|
10
|
-
badge:
|
|
10
|
+
badge: { label: "Live Item", className: "bg-danger-surface text-error" },
|
|
11
11
|
priceLabel: "Price (₹)",
|
|
12
12
|
typeLabel: "Live Item",
|
|
13
13
|
showsStockQuantity: true,
|
|
@@ -7,7 +7,7 @@ export const config = {
|
|
|
7
7
|
slugPrefix: "prizedraw-",
|
|
8
8
|
cartLine: "single-product",
|
|
9
9
|
detailRoute: (idOrSlug) => String(ROUTES.PUBLIC.PRIZE_DRAW_DETAIL(idOrSlug)),
|
|
10
|
-
badge:
|
|
10
|
+
badge: { label: "Prize Draw", className: "bg-primary text-white" },
|
|
11
11
|
priceLabel: "Price (₹)",
|
|
12
12
|
typeLabel: "Prize Draw",
|
|
13
13
|
showsStockQuantity: false,
|