@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.
- package/dist/_internal/server/features/checkout/actions.js +27 -23
- package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
- package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
- package/dist/_internal/server/features/products/data.d.ts +2 -2
- package/dist/_internal/server/features/products/data.js +5 -23
- package/dist/_internal/server/features/products/index.d.ts +1 -0
- package/dist/_internal/server/features/products/index.js +1 -0
- package/dist/_internal/server/features/products/list-public.d.ts +111 -0
- package/dist/_internal/server/features/products/list-public.js +342 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
- package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
- package/dist/_internal/server/jobs/handlers/messages.js +5 -0
- package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
- package/dist/_internal/shared/checkout/lanes.js +106 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
- package/dist/_internal/shared/checkout/order-math.js +30 -6
- package/dist/_internal/shared/features/products/to-product-item.d.ts +25 -0
- package/dist/_internal/shared/features/products/to-product-item.js +45 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
- package/dist/client.d.ts +2 -1
- package/dist/client.js +4 -1
- package/dist/features/account/components/NotificationBell.js +1 -0
- package/dist/features/account/components/UserOffersPanel.js +8 -1
- package/dist/features/admin/actions/notification-actions.js +1 -1
- package/dist/features/admin/schemas/firestore.d.ts +2 -1
- package/dist/features/admin/schemas/firestore.js +1 -0
- package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
- package/dist/features/auctions/actions/bid-actions.js +29 -15
- package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
- package/dist/features/auctions/components/AuctionsListView.js +12 -53
- package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
- package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
- package/dist/features/auctions/repository/bid.repository.js +19 -0
- package/dist/features/auctions/schemas/firestore.d.ts +11 -1
- package/dist/features/auctions/schemas/firestore.js +1 -0
- package/dist/features/cart/actions/cart-actions.d.ts +11 -0
- package/dist/features/cart/actions/cart-actions.js +24 -0
- package/dist/features/cart/repository/cart.repository.d.ts +24 -1
- package/dist/features/cart/repository/cart.repository.js +71 -6
- package/dist/features/cart/schemas/firestore.d.ts +23 -2
- package/dist/features/contact/email.js +52 -1
- package/dist/features/grouped/components/GroupedListingsCarousel.js +4 -1
- package/dist/features/orders/repository/orders.repository.d.ts +0 -15
- package/dist/features/orders/repository/orders.repository.js +0 -27
- package/dist/features/orders/utils/order-splitter.js +14 -2
- package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
- package/dist/features/products/components/ArtStickersListView.js +10 -49
- package/dist/features/products/components/ProductsIndexPageView.js +9 -62
- package/dist/features/products/repository/products.repository.js +41 -5
- package/dist/features/seller/actions/offer-actions.d.ts +6 -0
- package/dist/features/seller/actions/offer-actions.js +8 -9
- package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
- package/dist/features/seller/components/SellerOffersView.js +59 -8
- package/dist/features/seller/repository/offer.repository.d.ts +18 -2
- package/dist/features/seller/repository/offer.repository.js +38 -2
- package/dist/features/seller/schemas/firestore.d.ts +7 -2
- package/dist/features/seller/schemas/firestore.js +3 -0
- package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
- package/dist/features/seller/schemas/offer-forms.js +28 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +3 -0
- 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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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"];
|
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|