@mohasinac/appkit 4.11.1 → 4.11.2

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 (63) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +27 -23
  2. package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
  3. package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
  4. package/dist/_internal/server/features/products/index.d.ts +1 -0
  5. package/dist/_internal/server/features/products/index.js +1 -0
  6. package/dist/_internal/server/features/products/list-public.d.ts +111 -0
  7. package/dist/_internal/server/features/products/list-public.js +342 -0
  8. package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
  9. package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
  10. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
  11. package/dist/_internal/server/jobs/handlers/messages.js +5 -0
  12. package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
  13. package/dist/_internal/shared/checkout/lanes.js +106 -0
  14. package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
  15. package/dist/_internal/shared/checkout/order-math.js +30 -6
  16. package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
  17. package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
  18. package/dist/client.d.ts +2 -1
  19. package/dist/client.js +4 -1
  20. package/dist/features/account/components/NotificationBell.js +1 -0
  21. package/dist/features/account/components/UserOffersPanel.js +8 -1
  22. package/dist/features/admin/actions/notification-actions.js +1 -1
  23. package/dist/features/admin/schemas/firestore.d.ts +2 -1
  24. package/dist/features/admin/schemas/firestore.js +1 -0
  25. package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
  26. package/dist/features/auctions/actions/bid-actions.js +29 -15
  27. package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
  28. package/dist/features/auctions/components/AuctionsListView.js +12 -53
  29. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
  30. package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
  31. package/dist/features/auctions/repository/bid.repository.js +19 -0
  32. package/dist/features/auctions/schemas/firestore.d.ts +11 -1
  33. package/dist/features/auctions/schemas/firestore.js +1 -0
  34. package/dist/features/cart/actions/cart-actions.d.ts +11 -0
  35. package/dist/features/cart/actions/cart-actions.js +24 -0
  36. package/dist/features/cart/repository/cart.repository.d.ts +24 -1
  37. package/dist/features/cart/repository/cart.repository.js +71 -6
  38. package/dist/features/cart/schemas/firestore.d.ts +23 -2
  39. package/dist/features/contact/email.js +52 -1
  40. package/dist/features/orders/repository/orders.repository.d.ts +0 -15
  41. package/dist/features/orders/repository/orders.repository.js +0 -27
  42. package/dist/features/orders/utils/order-splitter.js +14 -2
  43. package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
  44. package/dist/features/products/components/ArtStickersListView.js +10 -49
  45. package/dist/features/products/components/ProductsIndexPageView.js +9 -62
  46. package/dist/features/products/repository/products.repository.js +41 -5
  47. package/dist/features/seller/actions/offer-actions.d.ts +6 -0
  48. package/dist/features/seller/actions/offer-actions.js +8 -9
  49. package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
  50. package/dist/features/seller/components/SellerOffersView.js +59 -8
  51. package/dist/features/seller/repository/offer.repository.d.ts +18 -2
  52. package/dist/features/seller/repository/offer.repository.js +38 -2
  53. package/dist/features/seller/schemas/firestore.d.ts +7 -2
  54. package/dist/features/seller/schemas/firestore.js +3 -0
  55. package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
  56. package/dist/features/seller/schemas/offer-forms.js +28 -0
  57. package/dist/index.d.ts +2 -1
  58. package/dist/index.js +4 -1
  59. package/dist/server-entry.d.ts +1 -0
  60. package/dist/server-entry.js +1 -0
  61. package/dist/server.d.ts +1 -0
  62. package/dist/server.js +3 -0
  63. package/package.json +1 -1
@@ -1,75 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { productRepository } from "../../../repositories";
3
2
  import { ROUTES } from "../../../constants";
4
3
  import { Container, Div, Heading, Main, Section, Text, TextLink } from "../../../ui";
5
4
  import { AdSlot } from "../../homepage/components/AdSlot";
6
- import { parseListingSearchParams } from "../../../utils/listing-params";
7
5
  import { ProductsIndexListing } from "./ProductsIndexListing";
8
6
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
9
- import { sieveFilter, sieveMultiEq, sieveAnd, SIEVE_OP } from "../../../utils/sieve-builder";
10
7
  import { sortBy } from "../../../constants/sort";
11
8
  import { GENERIC_PRODUCT_LISTING_TYPES } from "../constants/listing-tabs";
12
- const DEFAULT_PAGE = 1;
9
+ import { listPublicProducts, parsePublicProductParams, } from "../../../_internal/server/features/products/list-public";
13
10
  const DEFAULT_PAGE_SIZE = 24;
14
11
  const DEFAULT_SORT = sortBy(PRODUCT_FIELDS.CREATED_AT);
15
- function sp(params, key) {
16
- const v = params[key];
17
- return Array.isArray(v) ? v[0] ?? "" : v ?? "";
18
- }
19
- function buildProductFilters(params) {
20
- const listingTypeParam = sp(params, "listingType");
21
- const listingType = listingTypeParam && GENERIC_PRODUCT_LISTING_TYPES.includes(listingTypeParam)
22
- ? listingTypeParam
23
- : GENERIC_PRODUCT_LISTING_TYPES.join("|");
24
- const parts = [
25
- sieveFilter(PRODUCT_FIELDS.STATUS, SIEVE_OP.EQ, PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED),
26
- sieveFilter(PRODUCT_FIELDS.LISTING_TYPE, SIEVE_OP.EQ, listingType),
27
- ];
28
- const condition = sp(params, "condition");
29
- if (condition) {
30
- const values = condition.split("|").filter(Boolean);
31
- if (values.length === 1)
32
- parts.push(sieveFilter(PRODUCT_FIELDS.CONDITION, SIEVE_OP.EQ, values[0]));
33
- // BUG FIX: pipe is invalid for ==; expand to multiple AND clauses
34
- else if (values.length > 1)
35
- parts.push(sieveMultiEq(PRODUCT_FIELDS.CONDITION, values));
36
- }
37
- const minPrice = sp(params, "minPrice");
38
- const maxPrice = sp(params, "maxPrice");
39
- if (minPrice)
40
- parts.push(sieveFilter(PRODUCT_FIELDS.PRICE, SIEVE_OP.GTE, minPrice));
41
- if (maxPrice)
42
- parts.push(sieveFilter(PRODUCT_FIELDS.PRICE, SIEVE_OP.LTE, maxPrice));
43
- const storeId = sp(params, "seller");
44
- if (storeId)
45
- parts.push(sieveFilter(PRODUCT_FIELDS.STORE_ID, SIEVE_OP.EQ, storeId));
46
- const freeShipping = sp(params, "freeShipping");
47
- if (freeShipping === "true") {
48
- parts.push(sieveFilter(PRODUCT_FIELDS.SHIPPING_PAID_BY, SIEVE_OP.EQ, PRODUCT_FIELDS.SHIPPING_PAID_BY_VALUES.SELLER));
49
- }
50
- // Mirror ProductsIndexListing's client-side "Show sold" default (same Root Cause
51
- // pattern as auctions' dateFrom default — SSR initialData is seeded into React
52
- // Query with staleTime:Infinity, so if this filter doesn't already exclude sold /
53
- // out-of-stock products, the client never refetches and they leak into the default view).
54
- const showSold = sp(params, "showSold") === "true";
55
- if (!showSold)
56
- parts.push(sieveFilter(PRODUCT_FIELDS.STOCK_QUANTITY, SIEVE_OP.GT, 0));
57
- return sieveAnd(...parts);
58
- }
59
12
  export async function ProductsIndexPageView({ searchParams = {} }) {
60
- const std = parseListingSearchParams(searchParams);
61
- const sort = std.sorts ?? DEFAULT_SORT;
62
- const page = std.page ?? DEFAULT_PAGE;
63
- const pageSize = std.pageSize ?? DEFAULT_PAGE_SIZE;
64
- const filters = buildProductFilters(searchParams);
65
- const result = await productRepository
66
- .list({
67
- filters,
68
- sorts: sort,
69
- page,
70
- pageSize,
71
- })
72
- .catch(() => null);
73
- const products = result ?? null;
13
+ // See ArtStickersListView — one shared filter implementation with
14
+ // /api/products, so SSR and the client refetch agree by construction.
15
+ const products = await listPublicProducts(parsePublicProductParams(searchParams, {
16
+ listingTypes: GENERIC_PRODUCT_LISTING_TYPES,
17
+ pageSize: DEFAULT_PAGE_SIZE,
18
+ sorts: DEFAULT_SORT,
19
+ hideSoldByDefault: true,
20
+ }));
74
21
  return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Products" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Discover amazing products and deals" }), _jsx(Div, { className: "mt-3", children: _jsx(TextLink, { variant: "bare", href: String(ROUTES.PUBLIC.AUCTIONS), rounded: "full", paddingX: "sm", paddingY: "2xs", size: "xs", weight: "medium", layout: "inline-flex", align: "center", gap: "sm", className: "border border-primary/30 bg-primary/10 text-primary-700 dark:text-primary-400 hover:bg-primary/15 transition-colors", children: "\uD83C\uDFF7\uFE0F Looking for unique deals? Browse Auctions \u2192" }) })] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing, { initialData: products }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
75
22
  }
@@ -3,6 +3,7 @@ import { increment, serverTimestamp } from "../../../contracts/field-ops";
3
3
  import { DatabaseError } from "../../../errors";
4
4
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
5
5
  import { cacheManager } from "../../../core";
6
+ import { serverLogger } from "../../../monitoring";
6
7
  import { generateUniqueId, slugify, buildSearchTokens, tokenizeQuery, generateBarcodeId } from "../../../utils";
7
8
  import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
8
9
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
@@ -31,6 +32,8 @@ const LISTING_KIND_ALIAS_MAP = {
31
32
  classified: LISTING_TYPE_VALUES.CLASSIFIED,
32
33
  "digital-code": LISTING_TYPE_VALUES.DIGITAL_CODE,
33
34
  live: LISTING_TYPE_VALUES.LIVE,
35
+ art: LISTING_TYPE_VALUES.ART,
36
+ stickers: LISTING_TYPE_VALUES.STICKERS,
34
37
  // Legacy → canonical.
35
38
  product: LISTING_TYPE_VALUES.STANDARD,
36
39
  preorder: LISTING_TYPE_VALUES.PRE_ORDER,
@@ -559,12 +562,45 @@ ProductRepository.FILTER_ALIASES = {
559
562
  if (operator !== "==" && operator !== "!=")
560
563
  return "";
561
564
  // Accept both canonical tokens (`standard`, `auction`, `pre-order`,
562
- // `prize-draw`) and legacy aliases (`product`, `preorder`, `prizedraw`).
563
- // `LISTING_KIND_ACCEPTED` is the single source of truth — keep it in
564
- // sync with `LISTING_KIND_ALIAS_MAP`.
565
- if (!LISTING_KIND_ACCEPTED.has(value))
565
+ // `prize-draw`, `art`, `stickers`, …) and legacy aliases (`product`,
566
+ // `preorder`, `prizedraw`). `LISTING_KIND_ACCEPTED` is the single source
567
+ // of truth — keep it in sync with `LISTING_KIND_ALIAS_MAP`.
568
+ //
569
+ // A pipe-joined value (`art|stickers`, `standard|classified|live`) is a
570
+ // sievejs OR-group that the Firebase adapter upgrades to a `.where(…,
571
+ // "in", …)` query — the combined browse pages (/products, /art) send
572
+ // exactly this. Before 2026-08-21 the whole string was tested against
573
+ // `LISTING_KIND_ACCEPTED` as one token, never matched, and the clause was
574
+ // dropped silently, so those pages queried with NO listing-type filter at
575
+ // all. Split first, map each part, then re-emit the OR-group.
576
+ const parts = value.split("|").filter(Boolean);
577
+ if (parts.length === 0)
566
578
  return "";
567
- return buildListingKindClause(value, operator === "!=");
579
+ const unknown = parts.filter((p) => !LISTING_KIND_ACCEPTED.has(p));
580
+ if (unknown.length > 0) {
581
+ // Loud, not silent — a dropped filter reads as "no results" downstream
582
+ // with nothing to trace it back to (Root Cause: the art/stickers bug).
583
+ serverLogger.warn("Unknown listingType filter value(s) — clause dropped", {
584
+ value,
585
+ unknown,
586
+ accepted: [...LISTING_KIND_ACCEPTED],
587
+ });
588
+ return "";
589
+ }
590
+ if (operator === "!=") {
591
+ // Firestore allows at most one `!=` per query, so a multi-value
592
+ // exclusion can't be expressed here — reject rather than half-apply it.
593
+ if (parts.length > 1) {
594
+ serverLogger.warn("Multi-value listingType != is not supported — clause dropped", { value });
595
+ return "";
596
+ }
597
+ return buildListingKindClause(parts[0], true);
598
+ }
599
+ const canonical = parts.map((p) => LISTING_KIND_ALIAS_MAP[p]);
600
+ // De-dupe: legacy aliases can collapse onto the same canonical token
601
+ // (e.g. `product|standard`), and Firestore rejects a duplicate `in` value.
602
+ const unique = [...new Set(canonical)];
603
+ return `${PRODUCT_FIELDS.LISTING_TYPE}==${unique.join("|")}`;
568
604
  },
569
605
  /**
570
606
  * Public catalog scope shorthand. Maps a single token to the canonical
@@ -5,6 +5,12 @@
5
5
  * Auth, rate-limiting, and input validation are handled by the calling server action.
6
6
  */
7
7
  import type { OfferDocument } from "../schemas";
8
+ /**
9
+ * How long an accepted offer stays claimable at its locked price. Shared by the
10
+ * seller-accept path, the buyer-accepts-counter path, and the expiry sweep so
11
+ * all three can't drift.
12
+ */
13
+ export declare const OFFER_CHECKOUT_WINDOW_MS: number;
8
14
  import type { CartDocument } from "../../cart/schemas/firestore";
9
15
  export interface MakeOfferInput {
10
16
  productId: string;
@@ -15,6 +15,12 @@ import { cartRepository } from "../../cart/repository/cart.repository";
15
15
  import { maskOfferForSeller } from "../../../security";
16
16
  import { ERROR_MESSAGES, AuthorizationError, ValidationError, NotFoundError, OFFER_ERROR_CODES, } from "../../../errors";
17
17
  import { OfferStatusValues } from "../schemas";
18
+ /**
19
+ * How long an accepted offer stays claimable at its locked price. Shared by the
20
+ * seller-accept path, the buyer-accepts-counter path, and the expiry sweep so
21
+ * all three can't drift.
22
+ */
23
+ export const OFFER_CHECKOUT_WINDOW_MS = 48 * 60 * 60 * 1000;
18
24
  const ERR_OFFER_NOT_FOUND = "Offer not found";
19
25
  const ERR_NOT_AUTHORISED = "Not authorized";
20
26
  // --- Buyer: Make an Offer --------------------------------------------------
@@ -89,12 +95,8 @@ export async function respondToOffer(userId, input) {
89
95
  if (new Date() > offer.expiresAt)
90
96
  throw new ValidationError(ERROR_MESSAGES.OFFER.EXPIRED);
91
97
  let updated;
92
- const CHECKOUT_WINDOW_MS = 48 * 60 * 60 * 1000;
93
98
  if (action === "accept") {
94
- updated = await offerRepository.accept(offerId, offer.offerAmount, sellerNote);
95
- await offerRepository.update(offerId, {
96
- checkoutDeadline: new Date(Date.now() + CHECKOUT_WINDOW_MS),
97
- });
99
+ updated = await offerRepository.accept(offerId, offer.offerAmount, sellerNote, new Date(Date.now() + OFFER_CHECKOUT_WINDOW_MS));
98
100
  }
99
101
  else if (action === "decline") {
100
102
  updated = await offerRepository.decline(offerId, sellerNote);
@@ -144,10 +146,7 @@ export async function acceptCounterOffer(userId, offerId) {
144
146
  throw new ValidationError("No counter to accept.");
145
147
  if (new Date() > offer.expiresAt)
146
148
  throw new ValidationError(ERROR_MESSAGES.OFFER.EXPIRED);
147
- const updated = await offerRepository.acceptCounter(offerId);
148
- await offerRepository.update(offerId, {
149
- checkoutDeadline: new Date(Date.now() + 48 * 60 * 60 * 1000),
150
- });
149
+ const updated = await offerRepository.acceptCounter(offerId, new Date(Date.now() + OFFER_CHECKOUT_WINDOW_MS));
151
150
  const counterStore = offer.storeId ? await storeRepository.findById(offer.storeId) : null;
152
151
  if (counterStore?.ownerId)
153
152
  await sendNotification({
@@ -3,6 +3,11 @@ import type { ListingLayoutProps } from "../../../ui";
3
3
  export interface SellerOffersViewProps extends ListingLayoutProps {
4
4
  onAcceptOffer?: (id: string) => Promise<void>;
5
5
  onRejectOffer?: (id: string) => Promise<void>;
6
- onCounterOffer?: (id: string) => void;
6
+ /**
7
+ * Countering needs an amount, so this view hosts the form (a QuickFormDrawer,
8
+ * per Rule #9) and hands the consumer the collected values — rather than the
9
+ * old `(id) => void` shape, which left every consumer to invent its own input.
10
+ */
11
+ onCounterOffer?: (id: string, counterAmount: number, sellerNote?: string) => Promise<void>;
7
12
  }
8
13
  export declare function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCounterOffer, ...props }: SellerOffersViewProps): React.JSX.Element;
@@ -1,15 +1,20 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit/client";
4
4
  import { sortBy } from "@mohasinac/appkit/client";
5
5
  import React from "react";
6
- import { FilterChipGroup, ListingLayout, RowActionMenu } from "../../../ui";
6
+ import { FilterChipGroup, ListingLayout, RowActionMenu, RecordDetailModal } from "../../../ui";
7
+ import { QuickFormDrawer } from "../../shell/QuickFormDrawer";
8
+ import { counterOfferFormSchema } from "../schemas/offer-forms";
7
9
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
8
10
  import { SELLER_OFFER_STATUS_TABS } from "../../admin/constants/filter-tabs";
9
11
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
10
12
  import { toRecordArray, toRelativeDate, toCurrency, toStringValue, } from "../../admin/hooks/useAdminListingData";
11
13
  import { DataListingView } from "../../admin/components/DataListingView";
12
14
  export function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCounterOffer, ...props }) {
15
+ const [detailRow, setDetailRow] = React.useState(null);
16
+ const [counterRow, setCounterRow] = React.useState(null);
17
+ const [isCountering, setIsCountering] = React.useState(false);
13
18
  if (React.Children.count(children) > 0) {
14
19
  return (_jsx(ListingLayout, { portal: "seller", ...props, children: children }));
15
20
  }
@@ -26,26 +31,31 @@ export function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCou
26
31
  { value: sortBy("createdAt", "DESC"), label: "Newest" },
27
32
  { value: sortBy("createdAt", "ASC"), label: "Oldest" },
28
33
  ],
29
- mapRows: (response) => toRecordArray(response.offers).map((item, index) => ({
34
+ mapRows: (response) => toRecordArray(response.items).map((item, index) => ({
30
35
  id: toStringValue(item.id, `offer-${index}`),
31
36
  primary: toStringValue(item.productTitle ?? item.title, "Untitled product"),
32
37
  secondary: [
33
38
  `Offer: ${toCurrency(item.offerAmount ?? item.amount)}`,
39
+ `Listed: ${toCurrency(item.listedPrice)}`,
34
40
  toStringValue(item.buyerName ?? "Unknown buyer", "Unknown buyer"),
35
41
  ].join(" · "),
36
42
  status: toStringValue(item.status, "Pending"),
37
43
  updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
44
+ detail: item,
38
45
  })),
39
- getTotal: (response, mappedRows) => typeof response.meta?.total === "number"
40
- ? response.meta.total
41
- : mappedRows.length,
46
+ getTotal: (response, mappedRows) => typeof response.total === "number" ? response.total : mappedRows.length,
47
+ // A menu of pure mutations is not a detail affordance — the seller must be
48
+ // able to READ the offer (buyer note, listed vs offered price, expiry)
49
+ // before accepting or declining it (Root Cause #56).
50
+ onRowClick: (row) => setDetailRow(row),
42
51
  buildFilters: (state) => state.status && state.status !== "All" ? sieveFilter("status", SIEVE_OP.EQ, state.status) : undefined,
43
52
  renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
53
+ { label: "View details", onClick: () => setDetailRow(row) },
44
54
  ...(onAcceptOffer
45
55
  ? [{ label: ACTIONS.STORE["accept-offer"].label, onClick: () => void onAcceptOffer(row.id) }]
46
56
  : []),
47
57
  ...(onCounterOffer
48
- ? [{ label: ACTIONS.STORE["counter-offer"].label, onClick: () => onCounterOffer(row.id) }]
58
+ ? [{ label: ACTIONS.STORE["counter-offer"].label, onClick: () => setCounterRow(row) }]
49
59
  : []),
50
60
  ...(onRejectOffer
51
61
  ? [{ label: ACTIONS.STORE["reject-offer"].label, destructive: true, onClick: () => void onRejectOffer(row.id) }]
@@ -53,5 +63,46 @@ export function SellerOffersView({ children, onAcceptOffer, onRejectOffer, onCou
53
63
  ] })),
54
64
  renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(FilterChipGroup, { label: "Status", tabs: SELLER_OFFER_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) })),
55
65
  };
56
- return _jsx(DataListingView, { config: config });
66
+ const d = detailRow?.detail ?? {};
67
+ return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detailRow !== null, onClose: () => setDetailRow(null), title: detailRow?.primary ?? "Offer", badges: detailRow ? [{ label: detailRow.status }] : undefined, description: toStringValue(d.buyerNote, ""), fields: [
68
+ { label: "Buyer", value: toStringValue(d.buyerName, "Unknown buyer") },
69
+ { label: "Listed price", value: toCurrency(d.listedPrice) },
70
+ { label: "Offered", value: toCurrency(d.offerAmount) },
71
+ ...(d.counterAmount
72
+ ? [{ label: "Your counter", value: toCurrency(d.counterAmount) }]
73
+ : []),
74
+ ...(d.lockedPrice
75
+ ? [{ label: "Agreed price", value: toCurrency(d.lockedPrice) }]
76
+ : []),
77
+ { label: "Offer expires", value: toRelativeDate(d.expiresAt) },
78
+ ...(d.checkoutDeadline
79
+ ? [{ label: "Buyer must pay by", value: toRelativeDate(d.checkoutDeadline) }]
80
+ : []),
81
+ ...(d.sellerNote ? [{ label: "Your note", value: toStringValue(d.sellerNote, "") }] : []),
82
+ ] }), _jsx(QuickFormDrawer, { isOpen: counterRow !== null, onClose: () => setCounterRow(null), title: `Counter offer — ${counterRow?.primary ?? ""}`, submitLabel: "Send counter", isLoading: isCountering, schema: counterOfferFormSchema, fields: [
83
+ {
84
+ name: "counterAmount",
85
+ label: "Your counter price (₹)",
86
+ type: "number",
87
+ required: true,
88
+ helperText: "Must be below your listed price and different from the buyer's offer.",
89
+ },
90
+ {
91
+ name: "sellerNote",
92
+ label: "Note to buyer (optional)",
93
+ type: "textarea",
94
+ placeholder: "Best I can do is …",
95
+ },
96
+ ], onSubmit: async (values) => {
97
+ if (!counterRow || !onCounterOffer)
98
+ return;
99
+ setIsCountering(true);
100
+ try {
101
+ await onCounterOffer(counterRow.id, Number(values.counterAmount), values.sellerNote ? String(values.sellerNote) : undefined);
102
+ setCounterRow(null);
103
+ }
104
+ finally {
105
+ setIsCountering(false);
106
+ }
107
+ } })] }));
57
108
  }
@@ -51,15 +51,31 @@ declare class OfferRepository extends BaseRepository<OfferDocument> {
51
51
  */
52
52
  countByBuyerAndProduct(buyerUid: string, productId: string, since: Date): Promise<number>;
53
53
  updateStatus(offerId: string, patch: OfferUpdateInput): Promise<OfferDocument>;
54
- accept(offerId: string, lockedPrice: number, sellerNote?: string): Promise<OfferDocument>;
54
+ accept(offerId: string, lockedPrice: number, sellerNote?: string, checkoutDeadline?: Date): Promise<OfferDocument>;
55
55
  decline(offerId: string, sellerNote?: string): Promise<OfferDocument>;
56
56
  counter(offerId: string, counterAmount: number, sellerNote?: string): Promise<OfferDocument>;
57
- acceptCounter(offerId: string): Promise<OfferDocument>;
57
+ acceptCounter(offerId: string, checkoutDeadline?: Date): Promise<OfferDocument>;
58
58
  withdraw(offerId: string): Promise<OfferDocument>;
59
+ /**
60
+ * Terminal transition, called once the buyer's order for this offer exists.
61
+ *
62
+ * `"paid"` has been in `OfferStatusValues` since the feature shipped but had
63
+ * NO server-side writer — the only thing that ever set it was an optimistic
64
+ * client-side patch in UserOffersPanel, which meant a reload showed the offer
65
+ * back at "accepted" and it could be added to the cart and ordered again.
66
+ */
67
+ markPaid(offerId: string, orderId: string): Promise<OfferDocument>;
59
68
  /**
60
69
  * Cloud Functions compatibility: pending/countered offers already expired.
61
70
  */
62
71
  findExpiredActive(now: Date): Promise<OfferDocument[]>;
72
+ /**
73
+ * Accepted offers whose 48h checkout window has lapsed. These were never
74
+ * swept before — `findExpiredActive` only looks at pending/countered — so an
75
+ * accepted-but-never-paid offer stayed "accepted" forever and its locked
76
+ * price remained claimable long after the seller expected it to lapse.
77
+ */
78
+ findExpiredAccepted(now: Date): Promise<OfferDocument[]>;
63
79
  expireMany(offerIds: string[]): Promise<void>;
64
80
  }
65
81
  export declare const offerRepository: OfferRepository;
@@ -134,13 +134,18 @@ class OfferRepository extends BaseRepository {
134
134
  updatedAt: new Date(),
135
135
  });
136
136
  }
137
- async accept(offerId, lockedPrice, sellerNote) {
137
+ async accept(offerId, lockedPrice, sellerNote, checkoutDeadline) {
138
+ // One write, not two. `checkoutDeadline` used to be missing from
139
+ // OfferUpdateInput, which forced callers to follow every accept() with a
140
+ // separate generic update() — leaving a window where an offer was
141
+ // "accepted" with no deadline at all.
138
142
  return this.updateStatus(offerId, {
139
143
  status: "accepted",
140
144
  lockedPrice,
141
145
  sellerNote,
142
146
  acceptedAt: new Date(),
143
147
  respondedAt: new Date(),
148
+ ...(checkoutDeadline ? { checkoutDeadline } : {}),
144
149
  });
145
150
  }
146
151
  async decline(offerId, sellerNote) {
@@ -158,7 +163,7 @@ class OfferRepository extends BaseRepository {
158
163
  respondedAt: new Date(),
159
164
  });
160
165
  }
161
- async acceptCounter(offerId) {
166
+ async acceptCounter(offerId, checkoutDeadline) {
162
167
  const offer = await this.findById(offerId);
163
168
  if (!offer || !offer.counterAmount)
164
169
  throw new Error("Offer or counter not found");
@@ -167,6 +172,7 @@ class OfferRepository extends BaseRepository {
167
172
  lockedPrice: offer.counterAmount,
168
173
  acceptedAt: new Date(),
169
174
  respondedAt: new Date(),
175
+ ...(checkoutDeadline ? { checkoutDeadline } : {}),
170
176
  });
171
177
  }
172
178
  async withdraw(offerId) {
@@ -175,6 +181,21 @@ class OfferRepository extends BaseRepository {
175
181
  respondedAt: new Date(),
176
182
  });
177
183
  }
184
+ /**
185
+ * Terminal transition, called once the buyer's order for this offer exists.
186
+ *
187
+ * `"paid"` has been in `OfferStatusValues` since the feature shipped but had
188
+ * NO server-side writer — the only thing that ever set it was an optimistic
189
+ * client-side patch in UserOffersPanel, which meant a reload showed the offer
190
+ * back at "accepted" and it could be added to the cart and ordered again.
191
+ */
192
+ async markPaid(offerId, orderId) {
193
+ return this.updateStatus(offerId, {
194
+ status: "paid",
195
+ paidOrderId: orderId,
196
+ paidAt: new Date(),
197
+ });
198
+ }
178
199
  /**
179
200
  * Cloud Functions compatibility: pending/countered offers already expired.
180
201
  */
@@ -187,6 +208,21 @@ class OfferRepository extends BaseRepository {
187
208
  .get();
188
209
  return snapshot.docs.map((doc) => this.mapDoc(doc));
189
210
  }
211
+ /**
212
+ * Accepted offers whose 48h checkout window has lapsed. These were never
213
+ * swept before — `findExpiredActive` only looks at pending/countered — so an
214
+ * accepted-but-never-paid offer stayed "accepted" forever and its locked
215
+ * price remained claimable long after the seller expected it to lapse.
216
+ */
217
+ async findExpiredAccepted(now) {
218
+ const snapshot = await this.db
219
+ .collection(this.collection)
220
+ .where(OFFER_FIELDS.STATUS, "==", "accepted")
221
+ .where(OFFER_FIELDS.CHECKOUT_DEADLINE, "<=", now)
222
+ .limit(500)
223
+ .get();
224
+ return snapshot.docs.map((doc) => this.mapDoc(doc));
225
+ }
190
226
  async expireMany(offerIds) {
191
227
  const batch = this.db.batch();
192
228
  const now = new Date();
@@ -36,9 +36,12 @@ export interface OfferDocument extends BaseDocument {
36
36
  acceptedAt?: Date;
37
37
  checkoutDeadline?: Date;
38
38
  respondedAt?: Date;
39
+ /** Set by `markPaid` once the buyer's order exists — makes "paid" auditable. */
40
+ paidOrderId?: string;
41
+ paidAt?: Date;
39
42
  }
40
43
  export declare const OFFER_COLLECTION: "offers";
41
- export declare const OFFER_INDEXED_FIELDS: readonly ["buyerUid", "storeId", "productId", "status", "createdAt", "expiresAt"];
44
+ export declare const OFFER_INDEXED_FIELDS: readonly ["buyerUid", "storeId", "productId", "status", "createdAt", "expiresAt", "checkoutDeadline"];
42
45
  export declare const OFFER_FIELDS: {
43
46
  readonly ID: "id";
44
47
  readonly PRODUCT_ID: "productId";
@@ -60,11 +63,13 @@ export declare const OFFER_FIELDS: {
60
63
  readonly ACCEPTED_AT: "acceptedAt";
61
64
  readonly CHECKOUT_DEADLINE: "checkoutDeadline";
62
65
  readonly RESPONDED_AT: "respondedAt";
66
+ readonly PAID_ORDER_ID: "paidOrderId";
67
+ readonly PAID_AT: "paidAt";
63
68
  readonly CREATED_AT: "createdAt";
64
69
  readonly UPDATED_AT: "updatedAt";
65
70
  };
66
71
  export type OfferCreateInput = Pick<OfferDocument, "productId" | "productTitle" | "productSlug" | "productImageUrl" | "buyerUid" | "buyerName" | "buyerEmail" | "storeId" | "storeName" | "offerAmount" | "listedPrice" | "currency" | "buyerNote">;
67
- export type OfferUpdateInput = Partial<Pick<OfferDocument, "status" | "counterAmount" | "lockedPrice" | "sellerNote" | "acceptedAt" | "respondedAt" | "updatedAt">>;
72
+ export type OfferUpdateInput = Partial<Pick<OfferDocument, "status" | "counterAmount" | "lockedPrice" | "sellerNote" | "acceptedAt" | "checkoutDeadline" | "respondedAt" | "paidOrderId" | "paidAt" | "updatedAt">>;
68
73
  export declare const offerQueryHelpers: {
69
74
  readonly byBuyer: (buyerUid: string) => readonly ["buyerUid", "==", string];
70
75
  readonly byStore: (storeId: string) => readonly ["storeId", "==", string];
@@ -20,6 +20,7 @@ export const OFFER_INDEXED_FIELDS = [
20
20
  "status",
21
21
  "createdAt",
22
22
  "expiresAt",
23
+ "checkoutDeadline",
23
24
  ];
24
25
  export const OFFER_FIELDS = {
25
26
  ID: "id",
@@ -42,6 +43,8 @@ export const OFFER_FIELDS = {
42
43
  ACCEPTED_AT: "acceptedAt",
43
44
  CHECKOUT_DEADLINE: "checkoutDeadline",
44
45
  RESPONDED_AT: "respondedAt",
46
+ PAID_ORDER_ID: "paidOrderId",
47
+ PAID_AT: "paidAt",
45
48
  CREATED_AT: "createdAt",
46
49
  UPDATED_AT: "updatedAt",
47
50
  };
@@ -0,0 +1,12 @@
1
+ import { z } from "zod";
2
+ export declare const counterOfferFormSchema: z.ZodObject<{
3
+ counterAmount: z.ZodNumber;
4
+ sellerNote: z.ZodOptional<z.ZodString>;
5
+ }, "strip", z.ZodTypeAny, {
6
+ counterAmount: number;
7
+ sellerNote?: string | undefined;
8
+ }, {
9
+ counterAmount: number;
10
+ sellerNote?: string | undefined;
11
+ }>;
12
+ export type CounterOfferFormValues = z.infer<typeof counterOfferFormSchema>;
@@ -0,0 +1,28 @@
1
+ /*
2
+ * WHY: Rule #9 — every appkit form is schema-driven. The seller's counter-offer
3
+ * drawer had no form at all before 2026-08-21 (the view took an
4
+ * `(id) => void` callback and left each consumer to invent an input), so
5
+ * `respondToOffer`'s counter branch had zero real callers.
6
+ * WHAT: Zod schema for the counter-offer QuickFormDrawer. Deliberately only the
7
+ * field-shape rules that can be checked without loading the offer — the
8
+ * relational rules (below listed price, different from the buyer's offer)
9
+ * stay server-side in `respondToOffer`, which is the only place that can
10
+ * be trusted with them.
11
+ *
12
+ * EXPORTS:
13
+ * counterOfferFormSchema, type CounterOfferFormValues
14
+ *
15
+ * @tag domain:seller,offers
16
+ * @tag layer:schema
17
+ * @tag pattern:none
18
+ * @tag access:isomorphic
19
+ * @tag consumers:SellerOffersView
20
+ * @tag sideEffects:none
21
+ */
22
+ import { z } from "zod";
23
+ export const counterOfferFormSchema = z.object({
24
+ counterAmount: z.coerce
25
+ .number({ invalid_type_error: "Enter a counter amount." })
26
+ .positive("Counter amount must be greater than zero."),
27
+ sellerNote: z.string().max(500, "Keep your note under 500 characters.").optional(),
28
+ });
package/dist/index.d.ts CHANGED
@@ -2457,7 +2457,7 @@ export { getProductFilterKeys } from "./features/products/index";
2457
2457
  export { getProductSortOptions } from "./features/products/index";
2458
2458
  export { getProductTableColumns } from "./features/products/index";
2459
2459
  export { normalizeListingType, isAuctionListing, isPreOrderListing, isStandardListing, isPrizeDrawListing, isClassifiedListing, isDigitalCodeListing, isLiveListing, isArtListing, isStickersListing, } from "./features/products/index";
2460
- export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, } from "./_internal/shared/listing-types/feature-flags";
2460
+ export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, } from "./_internal/shared/listing-types/feature-flags";
2461
2461
  export { actionTracker, setActionTrackerSink, resetActionTrackerSink, type ActionEvent, type ActionTrackerSink, } from "./_internal/shared/listing-types/action-tracker";
2462
2462
  export { cartRequiresShipping, cartIsDigitalOnly, cartIsChatOnly, } from "./_internal/shared/listing-types/cart-shipping";
2463
2463
  export { ACTIONS, action, act, canPerformAction, actionsForListingType, actionLabel, type ActionDef, type ActionKind, type ActionResource, type ActionTree, type ActionConfirmation, } from "./_internal/shared/actions/action-registry";
@@ -3264,3 +3264,4 @@ export { safeFireAndForget } from "./utils/safe-fire-forget";
3264
3264
  export { withRetry } from "./http/retry";
3265
3265
  export { normalizeError, getErrorMessage, isApiNormalized, isAppNormalized, isFirebaseAuthNormalized, isFirebaseFirestoreNormalized, isFirebaseStorageNormalized, isNativeNormalized, isNetworkNormalized, isUnknownNormalized, isZodNormalized, } from "./errors/normalize";
3266
3266
  export type { NormalizedError, NormalizedApiError, NormalizedAppError, NormalizedFirebaseAuthError, NormalizedFirebaseFirestoreError, NormalizedFirebaseStorageError, NormalizedNativeError, NormalizedNetworkError, NormalizedUnknownThrownValue, NormalizedZodError, } from "./errors/normalize";
3267
+ export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, type CartLane, type LaneAssignable, } from "./_internal/shared/checkout/lanes";
package/dist/index.js CHANGED
@@ -4463,7 +4463,7 @@ export { getProductTableColumns } from "./features/products/index";
4463
4463
  // SB-UNI-F 2026-05-13 â€" Phase 2 predicates added (classified/digital-code/live).
4464
4464
  export { normalizeListingType, isAuctionListing, isPreOrderListing, isStandardListing, isPrizeDrawListing, isClassifiedListing, isDigitalCodeListing, isLiveListing, isArtListing, isStickersListing, } from "./features/products/index";
4465
4465
  // SB-UNI-X4 2026-05-13 â€" per-type feature-flag helpers.
4466
- export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, } from "./_internal/shared/listing-types/feature-flags";
4466
+ export { isListingTypeEnabled, isCategoryTypeEnabled, enabledListingTypes, enabledCategoryTypes, ALL_LISTING_TYPES, } from "./_internal/shared/listing-types/feature-flags";
4467
4467
  // SB-UNI-X5 2026-05-13 â€" action telemetry sink.
4468
4468
  export { actionTracker, setActionTrackerSink, resetActionTrackerSink, } from "./_internal/shared/listing-types/action-tracker";
4469
4469
  // SB-UNI-S 2026-05-13 â€" cart-level shipping-requirement helpers.
@@ -5666,3 +5666,6 @@ export { withRetry } from "./http/retry";
5666
5666
  // Every catch (e: unknown) funnels through normalizeError(e) which returns
5667
5667
  // a NormalizedError discriminated union. Audit-catch-normalize enforces this.
5668
5668
  export { normalizeError, getErrorMessage, isApiNormalized, isAppNormalized, isFirebaseAuthNormalized, isFirebaseFirestoreNormalized, isFirebaseStorageNormalized, isNativeNormalized, isNetworkNormalized, isUnknownNormalized, isZodNormalized, } from "./errors/normalize";
5669
+ // Checkout lanes — the derived auction > offer > standard partition of the
5670
+ // cart, and the priority rule that decides which one may be checked out.
5671
+ export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, } from "./_internal/shared/checkout/lanes";
@@ -45,6 +45,7 @@ export { StoreClassifiedsPageView } from "./features/stores/components/StoreClas
45
45
  export { StoreDigitalCodesPageView } from "./features/stores/components/StoreDigitalCodesPageView";
46
46
  export { StoreLiveItemsPageView } from "./features/stores/components/StoreLiveItemsPageView";
47
47
  export { getProductForDetail, listSitemapProducts, type SitemapProduct, } from "./_internal/server/features/products/index";
48
+ export { listPublicProducts, parsePublicProductParams, PUBLIC_PRODUCT_MAX_PAGE_SIZE, type PublicProductListInput, type PublicProductListResult, type PublicProductListOptions, type PublicProductExecutor, type PublicProductQuery, } from "./_internal/server/features/products/index";
48
49
  export { getAuctionForDetail, getProductFeaturesForAuction, } from "./_internal/server/features/auctions/index";
49
50
  export { getPreOrderForDetail, getProductFeaturesForPreOrder, } from "./_internal/server/features/pre-orders/index";
50
51
  export { getBrandForDetail, getBrandCategoryForDetail, createBrandAction, updateBrandAction, deleteBrandAction, toggleBrandActiveAction, } from "./_internal/server/features/brands/index";
@@ -51,6 +51,7 @@ export { StoreDigitalCodesPageView } from "./features/stores/components/StoreDig
51
51
  export { StoreLiveItemsPageView } from "./features/stores/components/StoreLiveItemsPageView";
52
52
  // S2: products data layer — deduped via React.cache()
53
53
  export { getProductForDetail, listSitemapProducts, } from "./_internal/server/features/products/index";
54
+ export { listPublicProducts, parsePublicProductParams, PUBLIC_PRODUCT_MAX_PAGE_SIZE, } from "./_internal/server/features/products/index";
54
55
  // S3: auctions data layer — deduped via React.cache()
55
56
  export { getAuctionForDetail, getProductFeaturesForAuction, } from "./_internal/server/features/auctions/index";
56
57
  // S3: pre-orders data layer — deduped via React.cache()
package/dist/server.d.ts CHANGED
@@ -501,6 +501,7 @@ export type { GoogleReview, GoogleReviewsResult } from "./features/homepage/lib/
501
501
  export { GoogleReviewsSection } from "./features/homepage/components/GoogleReviewsSection";
502
502
  export type { GoogleReviewsSectionProps } from "./features/homepage/components/GoogleReviewsSection";
503
503
  export { getProductForDetail, listSitemapProducts, computeRelatedItems, toProductItem, type SitemapProduct, type RelatedItemsResult, } from "./_internal/server/features/products/index";
504
+ export { listPublicProducts, parsePublicProductParams, PUBLIC_PRODUCT_MAX_PAGE_SIZE, type PublicProductListInput, type PublicProductListResult, type PublicProductListOptions, type PublicProductExecutor, type PublicProductQuery, } from "./_internal/server/features/products/index";
504
505
  export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, buildBundleMetadata, renderBundleOg, renderBundleOgImage, addBundleToCartAction, type BundleDataOptions, type BundleMetadataOptions, type BundleOgData, } from "./_internal/server/features/bundles/index";
505
506
  export { getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand } from "./_internal/server/features/grouped/index";
506
507
  export { getAuctionForDetail, getProductFeaturesForAuction } from "./_internal/server/features/auctions/index";
package/dist/server.js CHANGED
@@ -1353,6 +1353,9 @@ export { GoogleReviewsSection } from "./features/homepage/components/GoogleRevie
1353
1353
  // ---------------------------------------------------------------------------
1354
1354
  // Data layers — each wrapped in React.cache for request-scoped dedup
1355
1355
  export { getProductForDetail, listSitemapProducts, computeRelatedItems, toProductItem, } from "./_internal/server/features/products/index";
1356
+ // The single public-listing query — every SSR listing view and /api/products
1357
+ // share it so their filter semantics cannot drift (Root Cause #30).
1358
+ export { listPublicProducts, parsePublicProductParams, PUBLIC_PRODUCT_MAX_PAGE_SIZE, } from "./_internal/server/features/products/index";
1356
1359
  // S-SBUNI-3/4 2026-05-13 — bundle data/metadata/og layer.
1357
1360
  export { getBundleForDetail, listBundleMembers, listFeaturedBundles, getRelatedBundles, resolveBundleMemberIds, resolveBundleOriginalTotal, buildBundleMetadata, renderBundleOg, renderBundleOgImage, addBundleToCartAction, } from "./_internal/server/features/bundles/index";
1358
1361
  export { getGroupsForProduct, getGroupsWithItemsForProduct, getGroupsForCategory, getGroupsForBrand } from "./_internal/server/features/grouped/index";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.11.1",
3
+ "version": "4.11.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"