@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.
Files changed (108) hide show
  1. package/dist/_internal/server/features/auctions/actions.js +3 -2
  2. package/dist/_internal/server/features/auctions/service.d.ts +4 -3
  3. package/dist/_internal/server/features/auctions/service.js +6 -6
  4. package/dist/_internal/server/features/bundles/data.d.ts +9 -0
  5. package/dist/_internal/server/features/bundles/data.js +14 -0
  6. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  7. package/dist/_internal/server/features/bundles/index.js +1 -1
  8. package/dist/_internal/server/features/catalogue/og.d.ts +20 -0
  9. package/dist/_internal/server/features/catalogue/og.js +27 -0
  10. package/dist/_internal/server/features/grouped/data.d.ts +12 -0
  11. package/dist/_internal/server/features/grouped/data.js +26 -1
  12. package/dist/_internal/server/features/grouped/index.d.ts +1 -1
  13. package/dist/_internal/server/features/grouped/index.js +1 -1
  14. package/dist/_internal/server/features/products/data.d.ts +23 -0
  15. package/dist/_internal/server/features/products/data.js +159 -0
  16. package/dist/_internal/server/features/products/index.d.ts +1 -1
  17. package/dist/_internal/server/features/products/index.js +1 -1
  18. package/dist/_internal/shared/features/auctions/config.d.ts +20 -0
  19. package/dist/_internal/shared/features/auctions/config.js +31 -1
  20. package/dist/_internal/shared/listing-types/art/config.d.ts +1 -1
  21. package/dist/_internal/shared/listing-types/art/config.js +1 -1
  22. package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -1
  23. package/dist/_internal/shared/listing-types/classified/config.js +1 -1
  24. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -1
  25. package/dist/_internal/shared/listing-types/digital-code/config.js +1 -1
  26. package/dist/_internal/shared/listing-types/live/config.d.ts +1 -1
  27. package/dist/_internal/shared/listing-types/live/config.js +1 -1
  28. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -1
  29. package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
  30. package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -1
  31. package/dist/_internal/shared/listing-types/stickers/config.js +1 -1
  32. package/dist/client.d.ts +0 -6
  33. package/dist/client.js +1 -4
  34. package/dist/constants/api-endpoints.d.ts +3 -0
  35. package/dist/constants/api-endpoints.js +1 -0
  36. package/dist/features/admin/components/AdminSiteSettingsView.js +12 -4
  37. package/dist/features/admin/schemas/firestore.d.ts +9 -0
  38. package/dist/features/admin/schemas/firestore.js +6 -0
  39. package/dist/features/auctions/actions/bid-actions.js +4 -1
  40. package/dist/features/auctions/components/AuctionBottomActions.d.ts +2 -1
  41. package/dist/features/auctions/components/AuctionBottomActions.js +14 -1
  42. package/dist/features/auctions/components/AuctionDetailPageView.js +21 -9
  43. package/dist/features/auctions/components/LiveMinIncrement.d.ts +18 -0
  44. package/dist/features/auctions/components/LiveMinIncrement.js +17 -0
  45. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +5 -2
  46. package/dist/features/auctions/components/PlaceBidFormClient.js +7 -3
  47. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.d.ts +11 -0
  48. package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +33 -0
  49. package/dist/features/catalogue/components/PublicCatalogueView.js +2 -1
  50. package/dist/features/catalogue/index.d.ts +2 -0
  51. package/dist/features/catalogue/index.js +1 -0
  52. package/dist/features/categories/components/BundleDetailView.d.ts +3 -1
  53. package/dist/features/categories/components/BundleDetailView.js +3 -2
  54. package/dist/features/classified/components/ClassifiedContactSellerPanel.d.ts +12 -0
  55. package/dist/features/classified/components/ClassifiedContactSellerPanel.js +49 -0
  56. package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +7 -0
  57. package/dist/features/classified/components/ClassifiedDetailPageView.js +69 -0
  58. package/dist/features/contact/components/ContactPageView.js +8 -2
  59. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.d.ts +19 -0
  60. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +79 -0
  61. package/dist/features/grouped/components/GroupedListingsCarousel.d.ts +13 -0
  62. package/dist/features/grouped/components/GroupedListingsCarousel.js +31 -0
  63. package/dist/features/grouped/repository/grouped-listings.repository.d.ts +8 -0
  64. package/dist/features/grouped/repository/grouped-listings.repository.js +16 -0
  65. package/dist/features/layout/BottomActions.js +4 -3
  66. package/dist/features/layout/BottomActionsContext.d.ts +10 -0
  67. package/dist/features/layout/BottomActionsContext.js +5 -1
  68. package/dist/features/layout/hooks/useBottomActions.d.ts +2 -0
  69. package/dist/features/layout/hooks/useBottomActions.js +4 -1
  70. package/dist/features/live/components/LiveItemDetailPageView.d.ts +9 -0
  71. package/dist/features/live/components/LiveItemDetailPageView.js +74 -0
  72. package/dist/features/media/MediaVideo.js +17 -1
  73. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +9 -1
  74. package/dist/features/products/components/PrizeDrawDetailPageView.js +11 -1
  75. package/dist/features/products/components/ProductDetailPageView.js +11 -116
  76. package/dist/features/products/components/RelatedItemsSection.d.ts +16 -0
  77. package/dist/features/products/components/RelatedItemsSection.js +17 -0
  78. package/dist/features/seller/components/SellerProductShell.js +1 -1
  79. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +83 -3
  80. package/dist/index.d.ts +2 -0
  81. package/dist/index.js +1 -0
  82. package/dist/next/routing/route-map.d.ts +2 -0
  83. package/dist/next/routing/route-map.js +7 -0
  84. package/dist/seed/bids-seed-data.js +48 -3
  85. package/dist/seed/grouped-listings-seed-data.js +167 -0
  86. package/dist/seed/payouts-seed-data.js +4 -4
  87. package/dist/seed/products-art-seed-data.js +188 -5
  88. package/dist/seed/products-auctions-seed-data.js +215 -6
  89. package/dist/seed/products-classifieds-seed-data.js +255 -8
  90. package/dist/seed/products-digital-codes-seed-data.js +258 -19
  91. package/dist/seed/products-live-items-seed-data.js +143 -10
  92. package/dist/seed/products-preorders-seed-data.js +213 -8
  93. package/dist/seed/products-prize-draws-seed-data.js +198 -0
  94. package/dist/seed/products-standard-seed-data.js +141 -0
  95. package/dist/seed/products-stickers-seed-data.js +178 -5
  96. package/dist/seed/site-settings-seed-data.js +10 -29
  97. package/dist/seed/store-extensions-seed-data.js +1 -1
  98. package/dist/server-entry.d.ts +4 -1
  99. package/dist/server-entry.js +4 -1
  100. package/dist/server.d.ts +4 -2
  101. package/dist/server.js +4 -2
  102. package/dist/styles.css +29 -1
  103. package/dist/tailwind-utilities.css +1 -1
  104. package/dist/ui/components/ImageLightbox.js +37 -3
  105. package/dist/ui/components/ImageLightbox.style.css +28 -0
  106. package/dist/utils/media-url.d.ts +9 -0
  107. package/dist/utils/media-url.js +39 -0
  108. 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
- assertBidAmount(product, amount);
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 { AUCTION_MIN_BID_INCREMENT, AUCTION_SNIPING_WINDOW_SECONDS, AUCTION_DEFAULT_EXTENSION_MINUTES, } from "../../../shared/features/auctions/config";
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 ?? AUCTION_MIN_BID_INCREMENT;
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
+ }
@@ -8,7 +8,7 @@ export declare const config: {
8
8
  badge: {
9
9
  label: string;
10
10
  className: string;
11
- } | undefined;
11
+ };
12
12
  priceLabel: string;
13
13
  typeLabel: string;
14
14
  showsStockQuantity: boolean;
@@ -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: undefined,
10
+ badge: { label: "Art Print", className: "bg-primary-700 text-white" },
11
11
  priceLabel: "Price (₹)",
12
12
  typeLabel: "Art Print",
13
13
  showsStockQuantity: true,
@@ -8,7 +8,7 @@ export declare const config: {
8
8
  badge: {
9
9
  label: string;
10
10
  className: string;
11
- } | undefined;
11
+ };
12
12
  priceLabel: string;
13
13
  typeLabel: string;
14
14
  showsStockQuantity: boolean;
@@ -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: undefined,
10
+ badge: { label: "Classified", className: "bg-secondary text-white" },
11
11
  priceLabel: "Asking Price (₹)",
12
12
  typeLabel: "Classified",
13
13
  showsStockQuantity: true,
@@ -8,7 +8,7 @@ export declare const config: {
8
8
  badge: {
9
9
  label: string;
10
10
  className: string;
11
- } | undefined;
11
+ };
12
12
  priceLabel: string;
13
13
  typeLabel: string;
14
14
  showsStockQuantity: boolean;
@@ -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: undefined,
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,
@@ -8,7 +8,7 @@ export declare const config: {
8
8
  badge: {
9
9
  label: string;
10
10
  className: string;
11
- } | undefined;
11
+ };
12
12
  priceLabel: string;
13
13
  typeLabel: string;
14
14
  showsStockQuantity: boolean;
@@ -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: undefined,
10
+ badge: { label: "Live Item", className: "bg-danger-surface text-error" },
11
11
  priceLabel: "Price (₹)",
12
12
  typeLabel: "Live Item",
13
13
  showsStockQuantity: true,
@@ -8,7 +8,7 @@ export declare const config: {
8
8
  badge: {
9
9
  label: string;
10
10
  className: string;
11
- } | undefined;
11
+ };
12
12
  priceLabel: string;
13
13
  typeLabel: string;
14
14
  showsStockQuantity: boolean;
@@ -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: undefined,
10
+ badge: { label: "Prize Draw", className: "bg-primary text-white" },
11
11
  priceLabel: "Price (₹)",
12
12
  typeLabel: "Prize Draw",
13
13
  showsStockQuantity: false,
@@ -8,7 +8,7 @@ export declare const config: {
8
8
  badge: {
9
9
  label: string;
10
10
  className: string;
11
- } | undefined;
11
+ };
12
12
  priceLabel: string;
13
13
  typeLabel: string;
14
14
  showsStockQuantity: boolean;