@mohasinac/appkit 4.11.0 → 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 (68) 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/data.d.ts +2 -2
  5. package/dist/_internal/server/features/products/data.js +5 -23
  6. package/dist/_internal/server/features/products/index.d.ts +1 -0
  7. package/dist/_internal/server/features/products/index.js +1 -0
  8. package/dist/_internal/server/features/products/list-public.d.ts +111 -0
  9. package/dist/_internal/server/features/products/list-public.js +342 -0
  10. package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
  11. package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
  12. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
  13. package/dist/_internal/server/jobs/handlers/messages.js +5 -0
  14. package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
  15. package/dist/_internal/shared/checkout/lanes.js +106 -0
  16. package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
  17. package/dist/_internal/shared/checkout/order-math.js +30 -6
  18. package/dist/_internal/shared/features/products/to-product-item.d.ts +25 -0
  19. package/dist/_internal/shared/features/products/to-product-item.js +45 -0
  20. package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
  21. package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
  22. package/dist/client.d.ts +2 -1
  23. package/dist/client.js +4 -1
  24. package/dist/features/account/components/NotificationBell.js +1 -0
  25. package/dist/features/account/components/UserOffersPanel.js +8 -1
  26. package/dist/features/admin/actions/notification-actions.js +1 -1
  27. package/dist/features/admin/schemas/firestore.d.ts +2 -1
  28. package/dist/features/admin/schemas/firestore.js +1 -0
  29. package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
  30. package/dist/features/auctions/actions/bid-actions.js +29 -15
  31. package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
  32. package/dist/features/auctions/components/AuctionsListView.js +12 -53
  33. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
  34. package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
  35. package/dist/features/auctions/repository/bid.repository.js +19 -0
  36. package/dist/features/auctions/schemas/firestore.d.ts +11 -1
  37. package/dist/features/auctions/schemas/firestore.js +1 -0
  38. package/dist/features/cart/actions/cart-actions.d.ts +11 -0
  39. package/dist/features/cart/actions/cart-actions.js +24 -0
  40. package/dist/features/cart/repository/cart.repository.d.ts +24 -1
  41. package/dist/features/cart/repository/cart.repository.js +71 -6
  42. package/dist/features/cart/schemas/firestore.d.ts +23 -2
  43. package/dist/features/contact/email.js +52 -1
  44. package/dist/features/grouped/components/GroupedListingsCarousel.js +4 -1
  45. package/dist/features/orders/repository/orders.repository.d.ts +0 -15
  46. package/dist/features/orders/repository/orders.repository.js +0 -27
  47. package/dist/features/orders/utils/order-splitter.js +14 -2
  48. package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
  49. package/dist/features/products/components/ArtStickersListView.js +10 -49
  50. package/dist/features/products/components/ProductsIndexPageView.js +9 -62
  51. package/dist/features/products/repository/products.repository.js +41 -5
  52. package/dist/features/seller/actions/offer-actions.d.ts +6 -0
  53. package/dist/features/seller/actions/offer-actions.js +8 -9
  54. package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
  55. package/dist/features/seller/components/SellerOffersView.js +59 -8
  56. package/dist/features/seller/repository/offer.repository.d.ts +18 -2
  57. package/dist/features/seller/repository/offer.repository.js +38 -2
  58. package/dist/features/seller/schemas/firestore.d.ts +7 -2
  59. package/dist/features/seller/schemas/firestore.js +3 -0
  60. package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
  61. package/dist/features/seller/schemas/offer-forms.js +28 -0
  62. package/dist/index.d.ts +2 -1
  63. package/dist/index.js +4 -1
  64. package/dist/server-entry.d.ts +1 -0
  65. package/dist/server-entry.js +1 -0
  66. package/dist/server.d.ts +1 -0
  67. package/dist/server.js +3 -0
  68. package/package.json +1 -1
@@ -1,62 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
3
- import { productRepository } from "../../../repositories";
4
2
  import { Container, Heading, Main, Section } from "../../../ui";
5
3
  import { AdSlot } from "../../homepage/components/AdSlot";
6
- import { parseListingSearchParams } from "../../../utils/listing-params";
7
4
  import { AuctionsIndexListing } from "../../products/components/AuctionsIndexListing";
8
- const DEFAULT_PAGE = 1;
5
+ import { listPublicProducts, parsePublicProductParams, } from "../../../_internal/server/features/products/list-public";
6
+ const AUCTION_LISTING_TYPES = ["auction"];
9
7
  const DEFAULT_PAGE_SIZE = 24;
10
8
  const DEFAULT_SORT = "auctionEndDate";
11
- function sp(params, key) {
12
- const v = params[key];
13
- return Array.isArray(v) ? v[0] ?? "" : v ?? "";
14
- }
15
- function buildAuctionFilters(params) {
16
- const parts = ["status==published", "listingType==auction"];
17
- const minBid = sp(params, "minBid");
18
- const maxBid = sp(params, "maxBid");
19
- if (minBid)
20
- parts.push(sieveFilter("currentBid", SIEVE_OP.GTE, minBid));
21
- if (maxBid)
22
- parts.push(sieveFilter("currentBid", SIEVE_OP.LTE, maxBid));
23
- const store = sp(params, "store");
24
- if (store) {
25
- const values = store.split("|").filter(Boolean);
26
- if (values.length === 1)
27
- parts.push(sieveFilter("storeId", SIEVE_OP.EQ, values[0]));
28
- else if (values.length > 1)
29
- parts.push(sieveFilter("storeId", SIEVE_OP.EQ, values.join("|")));
30
- }
31
- // Mirror AuctionsIndexListing's client-side default (Root Cause pattern —
32
- // SSR initialData is seeded into React Query with staleTime:Infinity, so if
33
- // this SSR filter doesn't already exclude ended auctions, the client never
34
- // refetches and ended auctions leak into the default "Show ended" off view).
35
- const showEnded = sp(params, "showEnded") === "true";
36
- const dateFrom = showEnded
37
- ? sp(params, "dateFrom")
38
- : sp(params, "dateFrom") || new Date().toISOString();
39
- const dateTo = sp(params, "dateTo");
40
- if (dateFrom)
41
- parts.push(sieveFilter("auctionEndDate", SIEVE_OP.GTE, dateFrom));
42
- if (dateTo)
43
- parts.push(sieveFilter("auctionEndDate", SIEVE_OP.LTE, dateTo));
44
- return parts.join(",");
45
- }
46
9
  export async function AuctionsListView({ searchParams = {} }) {
47
- const std = parseListingSearchParams(searchParams);
48
- const sort = std.sorts ?? DEFAULT_SORT;
49
- const page = std.page ?? DEFAULT_PAGE;
50
- const pageSize = std.pageSize ?? DEFAULT_PAGE_SIZE;
51
- const filters = buildAuctionFilters(searchParams);
52
- const result = await productRepository
53
- .list({
54
- filters,
55
- sorts: sort,
56
- page,
57
- pageSize,
58
- })
59
- .catch(() => null);
60
- const initial = result ?? null;
10
+ // Shared with /api/products. `hideEndedByDefault` mirrors
11
+ // AuctionsIndexListing's `showEnded ? … : dateFrom=now` default; because the
12
+ // default sort IS auctionEndDate, listPublicProducts pushes that range into
13
+ // Firestore rather than filtering in memory.
14
+ const initial = await listPublicProducts(parsePublicProductParams(searchParams, {
15
+ listingTypes: AUCTION_LISTING_TYPES,
16
+ pageSize: DEFAULT_PAGE_SIZE,
17
+ sorts: DEFAULT_SORT,
18
+ hideEndedByDefault: true,
19
+ }));
61
20
  return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Live Auctions" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(AuctionsIndexListing, { initialData: initial }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
62
21
  }
@@ -96,7 +96,16 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
96
96
  return;
97
97
  }
98
98
  setSuccess(true);
99
- router.refresh();
99
+ // Buy-Now writes a locked cart line, so send the buyer straight to the
100
+ // auction checkout lane to pay for it. This used to just call
101
+ // `router.refresh()` and discard the action's result entirely, leaving
102
+ // the buyer on the product page with no indication that anything was
103
+ // owed — the action returned an orderId nobody ever used.
104
+ const checkoutUrl = result?.checkoutUrl;
105
+ if (checkoutUrl)
106
+ router.push(checkoutUrl);
107
+ else
108
+ router.refresh();
100
109
  }
101
110
  catch (err) {
102
111
  void normalizeError(err);
@@ -50,6 +50,21 @@ export declare class BidRepository extends BaseRepository<BidDocument> {
50
50
  data: BidDocument;
51
51
  } | null>;
52
52
  markWon(batch: WriteBatch, ref: DocumentReference): void;
53
+ /**
54
+ * Link a won bid to the order it eventually became.
55
+ *
56
+ * `BidDocument` had no `orderId` at all before 2026-08-21, so a won bid and
57
+ * the thing the buyer paid for were two unrelated records — the "Won" row in
58
+ * /user/bids could not link anywhere, and nothing could tell a settled win
59
+ * from an unpaid one.
60
+ */
61
+ attachOrder(bidId: string, orderId: string): Promise<void>;
62
+ /**
63
+ * The winner never paid within their checkout window. Distinct from "lost"
64
+ * (outbid) — this bidder won and then defaulted, which a seller may want to
65
+ * act on.
66
+ */
67
+ markForfeited(bidId: string): Promise<void>;
53
68
  markLost(batch: WriteBatch, ref: DocumentReference): void;
54
69
  markOutbid(batch: WriteBatch, ref: DocumentReference): void;
55
70
  markWinning(batch: WriteBatch, ref: DocumentReference): void;
@@ -122,6 +122,25 @@ export class BidRepository extends BaseRepository {
122
122
  updatedAt: new Date(),
123
123
  });
124
124
  }
125
+ /**
126
+ * Link a won bid to the order it eventually became.
127
+ *
128
+ * `BidDocument` had no `orderId` at all before 2026-08-21, so a won bid and
129
+ * the thing the buyer paid for were two unrelated records — the "Won" row in
130
+ * /user/bids could not link anywhere, and nothing could tell a settled win
131
+ * from an unpaid one.
132
+ */
133
+ async attachOrder(bidId, orderId) {
134
+ await this.update(bidId, { orderId, updatedAt: new Date() });
135
+ }
136
+ /**
137
+ * The winner never paid within their checkout window. Distinct from "lost"
138
+ * (outbid) — this bidder won and then defaulted, which a seller may want to
139
+ * act on.
140
+ */
141
+ async markForfeited(bidId) {
142
+ await this.update(bidId, { status: "forfeited", isWinning: false, updatedAt: new Date() });
143
+ }
125
144
  markLost(batch, ref) {
126
145
  batch.update(ref, {
127
146
  status: "lost",
@@ -3,7 +3,9 @@
3
3
  */
4
4
  import { type GenerateBidIdInput } from "../../../utils/id-generators";
5
5
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
6
- export type BidStatus = "active" | "outbid" | "won" | "lost" | "cancelled";
6
+ export type BidStatus = "active" | "outbid" | "won" | "lost" | "cancelled"
7
+ /** Won, then the buyer let the 48h payment window lapse. */
8
+ | "forfeited";
7
9
  /** Runtime-accessible bid status values — use instead of bare string literals. */
8
10
  export declare const BidStatusValues: {
9
11
  readonly ACTIVE: "active";
@@ -11,6 +13,7 @@ export declare const BidStatusValues: {
11
13
  readonly WON: "won";
12
14
  readonly LOST: "lost";
13
15
  readonly CANCELLED: "cancelled";
16
+ readonly FORFEITED: "forfeited";
14
17
  };
15
18
  export interface BidDocument extends BaseDocument {
16
19
  productId: string;
@@ -25,6 +28,13 @@ export interface BidDocument extends BaseDocument {
25
28
  previousBidAmount?: number;
26
29
  bidDate: Date;
27
30
  autoMaxBid?: number;
31
+ /**
32
+ * The order this winning bid was settled into. Written by
33
+ * `finalizeLockedLines` once the winner actually pays; absent on a won-but-
34
+ * unpaid bid, which is what lets /user/bids show a "Pay now" CTA rather than
35
+ * a dead status badge.
36
+ */
37
+ orderId?: string;
28
38
  }
29
39
  export declare const BID_COLLECTION: "bids";
30
40
  export declare const BID_INDEXED_FIELDS: readonly ["productId", "userId", "status", "isWinning", "bidDate", "createdAt"];
@@ -9,6 +9,7 @@ export const BidStatusValues = {
9
9
  WON: "won",
10
10
  LOST: "lost",
11
11
  CANCELLED: "cancelled",
12
+ FORFEITED: "forfeited",
12
13
  };
13
14
  export const BID_COLLECTION = "bids";
14
15
  export const BID_INDEXED_FIELDS = [
@@ -6,6 +6,17 @@
6
6
  */
7
7
  import type { AddToCartInput, CartDocument, UpdateCartItemInput } from "../schemas";
8
8
  export declare function addItemToCart(userId: string, input: AddToCartInput): Promise<CartDocument>;
9
+ /** Error code the client maps to a lane-aware message. */
10
+ export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
11
+ /**
12
+ * Won-auction lines are non-removable and fixed-quantity — the buyer committed
13
+ * to the purchase by bidding, and letting them delete it would turn every
14
+ * auction into a free option. That's already enforced one layer down:
15
+ * `cartRepository.updateItem` / `removeItem` both reject a `locked` line, and
16
+ * auction settlement is the only writer that sets `locked: true`. Accepted
17
+ * offers deliberately do NOT set it — declining to buy is the buyer's right,
18
+ * and the offer simply lapses at its `checkoutDeadline`.
19
+ */
9
20
  export declare function updateCartItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
10
21
  export declare function removeCartItem(userId: string, itemId: string): Promise<CartDocument>;
11
22
  export declare function clearCart(userId: string): Promise<CartDocument>;
@@ -11,6 +11,7 @@ import { productRepository } from "../../products/repository/products.repository
11
11
  import { storeRepository } from "../../stores/repository/store.repository";
12
12
  import { normalizeListingType } from "../../products/utils/listing-type";
13
13
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
14
+ import { CART_LANE, activeLane, canAddNewItems, } from "../../../_internal/shared/checkout/lanes";
14
15
  /**
15
16
  * Product documents don't always carry a denormalized `storeName` — the
16
17
  * create routes only set it when the caller explicitly passes one, so
@@ -35,9 +36,32 @@ export async function addItemToCart(userId, input) {
35
36
  if (!canAddToCart(input.listingType)) {
36
37
  throw new ValidationError(`Listings of type "${input.listingType}" cannot be added to the cart.`);
37
38
  }
39
+ // Lane gate. While a higher-obligation lane (a won auction, then an accepted
40
+ // offer) is outstanding, no NEW shopping may be added — otherwise the buyer
41
+ // can keep deferring something they already committed to by piling more into
42
+ // the cart. Enforced here, on the server, not only in the UI: the UI hint is
43
+ // a courtesy, this is the rule.
44
+ const cart = await cartRepository.getOrCreate(userId);
45
+ if (!canAddNewItems(cart.items ?? [])) {
46
+ const blocking = activeLane(cart.items ?? []);
47
+ throw new ValidationError(blocking === CART_LANE.AUCTION
48
+ ? "Settle your won auction first — you can add other items once it's paid for."
49
+ : "Complete your accepted offer first — you can add other items once it's paid for.", { code: CART_LANE_BLOCKED });
50
+ }
38
51
  const storeName = await resolveStoreName(input.storeId, input.storeName);
39
52
  return cartRepository.addItem(userId, { ...input, storeName });
40
53
  }
54
+ /** Error code the client maps to a lane-aware message. */
55
+ export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
56
+ /**
57
+ * Won-auction lines are non-removable and fixed-quantity — the buyer committed
58
+ * to the purchase by bidding, and letting them delete it would turn every
59
+ * auction into a free option. That's already enforced one layer down:
60
+ * `cartRepository.updateItem` / `removeItem` both reject a `locked` line, and
61
+ * auction settlement is the only writer that sets `locked: true`. Accepted
62
+ * offers deliberately do NOT set it — declining to buy is the buyer's right,
63
+ * and the offer simply lapses at its `checkoutDeadline`.
64
+ */
41
65
  export async function updateCartItem(userId, itemId, input) {
42
66
  return cartRepository.updateItem(userId, itemId, input);
43
67
  }
@@ -1,7 +1,7 @@
1
1
  import type { DocumentReference } from "firebase-admin/firestore";
2
2
  import type { DocumentSnapshot } from "../../../providers/db-firebase";
3
3
  import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
4
- import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type UpdateCartItemInput } from "../schemas";
4
+ import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartItemDocument, type UpdateCartItemInput } from "../schemas";
5
5
  export declare class CartRepository extends BaseRepository<CartDocument> {
6
6
  private static readonly SIEVE_FIELDS;
7
7
  constructor();
@@ -19,6 +19,29 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
19
19
  getSubtotal(cart: CartDocument): number;
20
20
  addCoupon(userId: string, coupon: CartAppliedCoupon): Promise<void>;
21
21
  removeCoupon(userId: string, code: string): Promise<void>;
22
+ /**
23
+ * Drop every locked line matching a predicate, bypassing the `locked` guard
24
+ * that blocks user-initiated removal.
25
+ *
26
+ * Only the expiry sweeps call this: a locked line whose claim has lapsed must
27
+ * be cleared, or it keeps the buyer's auction/offer lane non-empty forever
28
+ * and the lane gate blocks their ordinary checkout on something they can no
29
+ * longer act on.
30
+ */
31
+ private removeLockedLines;
32
+ /** Clear the cart line created for a specific accepted offer. */
33
+ removeItemsByOfferId(userId: string, offerId: string): Promise<number>;
34
+ /** Clear the cart line created for a specific won auction bid. */
35
+ removeItemsByBidId(userId: string, bidId: string): Promise<number>;
36
+ /**
37
+ * Every locked line across all carts whose `checkoutDeadline` has passed.
38
+ * Bounded per Rule #6 — the sweep runs on a schedule, so a backlog drains
39
+ * over successive runs rather than in one unbounded scan.
40
+ */
41
+ findExpiredLockedLines(now: Date, limit?: number): Promise<Array<{
42
+ userId: string;
43
+ item: CartItemDocument;
44
+ }>>;
22
45
  updateItemShipping(userId: string, itemId: string, providerId: string, fee: number): Promise<CartDocument>;
23
46
  clearAllCoupons(userId: string): Promise<void>;
24
47
  setSelectedItems(userId: string, itemIds: string[] | null): Promise<void>;
@@ -61,14 +61,20 @@ export class CartRepository extends BaseRepository {
61
61
  try {
62
62
  const cart = await this.getOrCreate(userId);
63
63
  const items = [...cart.items];
64
- if (input.offerId) {
65
- const alreadyAdded = items.some((item) => item.offerId === input.offerId);
66
- if (alreadyAdded)
67
- return cart;
64
+ // A locked line (accepted offer / won auction) is identified by its
65
+ // offerId or bidId, never merged by productId — two separate wins on the
66
+ // same listing are two separate obligations, and re-running the
67
+ // settlement sweep must not duplicate a line it already wrote.
68
+ if (input.offerId && items.some((item) => item.offerId === input.offerId)) {
69
+ return cart;
68
70
  }
69
- const existingIndex = input.offerId
71
+ if (input.bidId && items.some((item) => item.bidId === input.bidId)) {
72
+ return cart;
73
+ }
74
+ const isLockedLine = Boolean(input.offerId || input.bidId);
75
+ const existingIndex = isLockedLine
70
76
  ? -1
71
- : items.findIndex((item) => item.productId === input.productId && !item.offerId);
77
+ : items.findIndex((item) => item.productId === input.productId && !item.offerId && !item.bidId);
72
78
  if (existingIndex >= 0) {
73
79
  items[existingIndex] = {
74
80
  ...items[existingIndex],
@@ -90,9 +96,15 @@ export class CartRepository extends BaseRepository {
90
96
  listingType: input.listingType,
91
97
  ...(input.isOffer !== undefined && { isOffer: input.isOffer }),
92
98
  ...(input.offerId !== undefined && { offerId: input.offerId }),
99
+ ...(input.isAuctionWin !== undefined && { isAuctionWin: input.isAuctionWin }),
100
+ ...(input.auctionId !== undefined && { auctionId: input.auctionId }),
101
+ ...(input.bidId !== undefined && { bidId: input.bidId }),
93
102
  ...(input.lockedPrice !== undefined && {
94
103
  lockedPrice: input.lockedPrice,
95
104
  }),
105
+ ...(input.checkoutDeadline !== undefined && {
106
+ checkoutDeadline: input.checkoutDeadline,
107
+ }),
96
108
  ...(input.locked !== undefined && { locked: input.locked }),
97
109
  // SB-UNI-4 2026-05-13 — propagate bundle identifiers when present.
98
110
  ...(input.bundleCategorySlug !== undefined && {
@@ -231,6 +243,59 @@ export class CartRepository extends BaseRepository {
231
243
  .doc(userId)
232
244
  .set({ appliedCoupons: updated, updatedAt: new Date() }, { merge: true });
233
245
  }
246
+ /**
247
+ * Drop every locked line matching a predicate, bypassing the `locked` guard
248
+ * that blocks user-initiated removal.
249
+ *
250
+ * Only the expiry sweeps call this: a locked line whose claim has lapsed must
251
+ * be cleared, or it keeps the buyer's auction/offer lane non-empty forever
252
+ * and the lane gate blocks their ordinary checkout on something they can no
253
+ * longer act on.
254
+ */
255
+ async removeLockedLines(userId, matches) {
256
+ const cart = await this.findByUserId(userId);
257
+ if (!cart)
258
+ return 0;
259
+ const items = cart.items.filter((item) => !matches(item));
260
+ const removed = cart.items.length - items.length;
261
+ if (removed === 0)
262
+ return 0;
263
+ await this.db
264
+ .collection(this.collection)
265
+ .doc(userId)
266
+ .set(prepareForFirestore({ ...cart, items, updatedAt: new Date() }));
267
+ return removed;
268
+ }
269
+ /** Clear the cart line created for a specific accepted offer. */
270
+ async removeItemsByOfferId(userId, offerId) {
271
+ return this.removeLockedLines(userId, (item) => item.offerId === offerId);
272
+ }
273
+ /** Clear the cart line created for a specific won auction bid. */
274
+ async removeItemsByBidId(userId, bidId) {
275
+ return this.removeLockedLines(userId, (item) => item.bidId === bidId);
276
+ }
277
+ /**
278
+ * Every locked line across all carts whose `checkoutDeadline` has passed.
279
+ * Bounded per Rule #6 — the sweep runs on a schedule, so a backlog drains
280
+ * over successive runs rather than in one unbounded scan.
281
+ */
282
+ async findExpiredLockedLines(now, limit = 200) {
283
+ const snapshot = await this.getCollection().limit(limit).get();
284
+ const out = [];
285
+ for (const doc of snapshot.docs) {
286
+ const cart = this.mapDoc(doc);
287
+ for (const item of cart.items ?? []) {
288
+ if (!item.checkoutDeadline)
289
+ continue;
290
+ const deadline = item.checkoutDeadline instanceof Date
291
+ ? item.checkoutDeadline
292
+ : new Date(item.checkoutDeadline);
293
+ if (deadline <= now)
294
+ out.push({ userId: cart.userId ?? doc.id, item });
295
+ }
296
+ }
297
+ return out;
298
+ }
234
299
  async updateItemShipping(userId, itemId, providerId, fee) {
235
300
  try {
236
301
  const cart = await this.findByUserId(userId);
@@ -20,15 +20,32 @@ export interface CartItemDocument {
20
20
  /**
21
21
  * Snapshot of the product's listing-kind at add-to-cart time (SB1-G Phase 4).
22
22
  * Drives order-grouping (auctions/pre-orders settle separately from standard
23
- * carts) and cart-side UI badges. Replaces the legacy `isAuction`/`isPreOrder`
23
+ * carts) and cart-side UI badges. Replaces the legacy `isAuctionWin`/`isPreOrder`
24
24
  * pair on the cart item.
25
25
  */
26
26
  listingType: ListingType;
27
27
  /** True when item was added from an accepted Make-an-Offer */
28
28
  isOffer?: boolean;
29
29
  offerId?: string;
30
- /** Locked offer price — overrides normal product price at checkout */
30
+ /**
31
+ * True when the line was created by auction settlement for the winning
32
+ * bidder. Auctions are `canAddToCart: false` for user-initiated adds — this
33
+ * line is written by the settlement job through the repository directly, and
34
+ * is the ONLY way an auction reaches the cart.
35
+ */
36
+ isAuctionWin?: boolean;
37
+ /** The auction product this win refers to (= productId, kept explicit). */
38
+ auctionId?: string;
39
+ /** The winning bid, so the resulting order can be linked back to it. */
40
+ bidId?: string;
41
+ /** Locked offer/winning-bid price — overrides normal product price at checkout */
31
42
  lockedPrice?: number;
43
+ /**
44
+ * When the claim on this locked price lapses. Mirrors the offer's own
45
+ * `checkoutDeadline`; set to now + 48h for a won auction. The expiry sweep
46
+ * clears lines past this point.
47
+ */
48
+ checkoutDeadline?: Date;
32
49
  /** When true the item cannot be removed or have its quantity changed. Set on won-auction and accepted-offer items that require mandatory payment. */
33
50
  locked?: boolean;
34
51
  /**
@@ -114,7 +131,11 @@ export type AddToCartInput = {
114
131
  listingType: ListingType;
115
132
  isOffer?: boolean;
116
133
  offerId?: string;
134
+ isAuctionWin?: boolean;
135
+ auctionId?: string;
136
+ bidId?: string;
117
137
  lockedPrice?: number;
138
+ checkoutDeadline?: Date;
118
139
  locked?: boolean;
119
140
  /** SB-UNI-4 2026-05-13 — bundle identifier when the line is a bundle. */
120
141
  bundleCategorySlug?: string;
@@ -13,6 +13,8 @@ function renderToStaticMarkup(el) {
13
13
  }
14
14
  import { normalizeError } from "../../errors/normalize";
15
15
  import { getProviders } from "../../contracts";
16
+ import { createResendProvider } from "../../providers/email-resend/provider";
17
+ import { siteSettingsRepository } from "../admin/repository/site-settings.repository";
16
18
  import { serverLogger } from "../../monitoring";
17
19
  import { currentYear, formatCurrency, formatDateTime, nowMs, } from "../../utils";
18
20
  import { EmailBold, EmailButton, EmailContainer, EmailDivider, EmailDoc, EmailFooter, EmailHeader, EmailLink, EmailRow, } from "../email/primitives";
@@ -25,9 +27,58 @@ function getSiteUrl() {
25
27
  function getSupportEmail() {
26
28
  return process.env.EMAIL_SUPPORT?.trim() || "support@example.com";
27
29
  }
30
+ /**
31
+ * Resolve an email provider that works in BOTH runtimes.
32
+ *
33
+ * The provider registry is populated by the consumer's `providers.config.ts`,
34
+ * which only runs from the Next.js instrumentation hook. Firebase Functions
35
+ * never call `registerProviders()`, so `getProviders()` throws there —
36
+ * silently failing every Functions-side `sendEmail()` with
37
+ * "Call registerProviders() before getProviders()". That is why the scheduled
38
+ * dailyStatusDigest never delivered: `sendEmail` catches the throw and returns
39
+ * it as `{ error }`, so it surfaced only as a logged "send failed".
40
+ *
41
+ * Falling back to a directly-constructed Resend provider mirrors exactly what
42
+ * `sendNotification()` already does for Functions-side email — Firestore
43
+ * credentials first, env vars second — so both paths resolve identically
44
+ * instead of one runtime silently having no provider at all.
45
+ */
46
+ async function resolveEmailProvider() {
47
+ try {
48
+ return getProviders().email;
49
+ }
50
+ catch {
51
+ const [creds, settings] = await Promise.all([
52
+ siteSettingsRepository.getDecryptedCredentials().catch((err) => {
53
+ void normalizeError(err);
54
+ return {};
55
+ }),
56
+ siteSettingsRepository.getSingleton().catch((err) => {
57
+ void normalizeError(err);
58
+ return null;
59
+ }),
60
+ ]);
61
+ // A seeded placeholder ("re_PLACEHOLDER") is truthy but not a real key and
62
+ // must never win over a real env var — same guard as providers.config.ts.
63
+ const seeded = creds.resendApiKey?.trim() ?? "";
64
+ const apiKey = seeded && !seeded.includes("PLACEHOLDER")
65
+ ? seeded
66
+ : (process.env.RESEND_API_KEY?.trim() ?? "");
67
+ return createResendProvider({
68
+ apiKey,
69
+ fromEmail: settings?.emailSettings?.fromEmail?.trim() ||
70
+ process.env.EMAIL_FROM?.trim() ||
71
+ "",
72
+ fromName: settings?.emailSettings?.fromName?.trim() ||
73
+ process.env.EMAIL_FROM_NAME?.trim() ||
74
+ "App",
75
+ });
76
+ }
77
+ }
28
78
  export async function sendEmail(opts) {
29
79
  try {
30
- const data = await getProviders().email.send({
80
+ const provider = await resolveEmailProvider();
81
+ const data = await provider.send({
31
82
  to: opts.to,
32
83
  subject: opts.subject,
33
84
  html: typeof opts.html === "string" ? opts.html : "",
@@ -3,7 +3,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
4
4
  import { SectionCarousel } from "../../homepage/components/SectionCarousel";
5
5
  import { ProductCard } from "../../products/components/ProductGrid";
6
- import { toProductItem } from "../../../_internal/server/features/products/data";
6
+ // Must come from _internal/shared — importing this as a VALUE from
7
+ // _internal/server/.../data.ts pulled firebase-admin into the client bundle
8
+ // and failed the Turbopack production build (Root Cause #6).
9
+ import { toProductItem } from "../../../_internal/shared/features/products/to-product-item";
7
10
  import { CAROUSEL_PER_VIEW } from "../../homepage/constants/carousel-per-view";
8
11
  import { Stack } from "../../../ui";
9
12
  const GROUP_THEME_TITLE = {
@@ -293,21 +293,6 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
293
293
  * Cloud Functions: stage a cancellation update into a caller-owned WriteBatch.
294
294
  */
295
295
  cancelInBatch(batch: WriteBatch, ref: DocumentReference): void;
296
- /**
297
- * Cloud Functions: create an auction-won order inside a caller-owned WriteBatch.
298
- * Returns the new DocumentReference synchronously so the caller can chain further batch ops.
299
- */
300
- createFromAuction(batch: WriteBatch, input: {
301
- productId: string;
302
- productTitle: string;
303
- userId: string;
304
- userName: string;
305
- userEmail: string;
306
- storeId?: string;
307
- amount: number;
308
- currency: string;
309
- auctionProductId: string;
310
- }): DocumentReference;
311
296
  }
312
297
  declare const orderRepository: OrderRepository;
313
298
  export { OrderRepository, orderRepository };
@@ -482,33 +482,6 @@ class OrderRepository extends BaseRepository {
482
482
  updatedAt: serverTimestamp(),
483
483
  });
484
484
  }
485
- /**
486
- * Cloud Functions: create an auction-won order inside a caller-owned WriteBatch.
487
- * Returns the new DocumentReference synchronously so the caller can chain further batch ops.
488
- */
489
- createFromAuction(batch, input) {
490
- const ref = this.db.collection(this.collection).doc();
491
- batch.create(ref, prepareForFirestore({
492
- id: ref.id,
493
- productId: input.productId,
494
- productTitle: input.productTitle,
495
- userId: input.userId,
496
- userName: input.userName,
497
- userEmail: input.userEmail,
498
- storeId: input.storeId ?? null,
499
- quantity: 1,
500
- unitPrice: input.amount,
501
- totalPrice: input.amount,
502
- currency: input.currency,
503
- status: OrderStatusValues.CONFIRMED,
504
- paymentStatus: "pending",
505
- orderDate: new Date(),
506
- notes: `Won via auction bid — auction product ${input.auctionProductId}`,
507
- createdAt: serverTimestamp(),
508
- updatedAt: serverTimestamp(),
509
- }));
510
- return ref;
511
- }
512
485
  }
513
486
  OrderRepository.SELLER_SIEVE_FIELDS = {
514
487
  id: { canFilter: true, canSort: false },
@@ -1,4 +1,5 @@
1
- import { getListingRule, offerRule, } from "../../../_internal/shared/checkout/rules";
1
+ import { getListingRule, offerRule, auctionRule, } from "../../../_internal/shared/checkout/rules";
2
+ import { CART_LANE, laneOf } from "../../../_internal/shared/checkout/lanes";
2
3
  /**
3
4
  * Split a sequence of cart-items into order groups by `OrderType`.
4
5
  *
@@ -18,7 +19,18 @@ export function splitCartIntoOrderGroups(checks) {
18
19
  for (const check of checks) {
19
20
  const { item } = check;
20
21
  const lt = (item.listingType ?? "standard");
21
- const rule = item.isOffer ? offerRule : (getListingRule(lt) ?? getListingRule("standard"));
22
+ // Lane first, listing type second. A locked line's obligation not the
23
+ // product's listing type — decides how it settles: an accepted offer is
24
+ // always its own order, and a won auction is always its own order even
25
+ // though `auctionRule` is otherwise unreachable (auctions are
26
+ // `cartEligible: false` for user-initiated adds; the only way one reaches
27
+ // the cart is auction settlement writing the winner's locked line).
28
+ const lane = laneOf(item);
29
+ const rule = lane === CART_LANE.OFFER
30
+ ? offerRule
31
+ : lane === CART_LANE.AUCTION
32
+ ? auctionRule
33
+ : (getListingRule(lt) ?? getListingRule("standard"));
22
34
  // Prize-draw (and future types with maxLinesPerOrder < Infinity) may split
23
35
  // one cart item into N virtual batch items.
24
36
  const virtualItems = rule.splitMultipleOrders(item);