@mohasinac/appkit 4.22.0 → 4.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/bundles/actions.js +7 -0
- package/dist/_internal/server/features/checkout/actions.js +24 -16
- package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +5 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
- package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
- package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
- package/dist/_internal/shared/actions/action-registry.js +41 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
- package/dist/_internal/shared/checkout/order-math.js +29 -6
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
- package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
- package/dist/_internal/shared/features/cart/config.d.ts +2 -1
- package/dist/_internal/shared/features/cart/config.js +2 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
- package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
- package/dist/_internal/shared/features/notifications/action-url.js +111 -0
- package/dist/_internal/shared/features/products/schema.d.ts +8 -8
- package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/types.d.ts +16 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/constants/notification-types.d.ts +38 -10
- package/dist/constants/notification-types.js +39 -28
- package/dist/features/account/types/index.d.ts +1 -10
- package/dist/features/admin/actions/admin-actions.js +13 -3
- package/dist/features/admin/actions/notification-actions.d.ts +12 -0
- package/dist/features/admin/actions/notification-actions.js +149 -4
- package/dist/features/admin/components/AdminBidsView.js +7 -1
- package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
- package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
- package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
- package/dist/features/admin/schemas/firestore.d.ts +14 -3
- package/dist/features/admin/schemas/firestore.js +79 -0
- package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
- package/dist/features/auctions/repository/bid.repository.js +57 -33
- package/dist/features/auctions/schemas/firestore.d.ts +27 -0
- package/dist/features/auctions/schemas/firestore.js +21 -0
- package/dist/features/auth/schemas/index.d.ts +2 -2
- package/dist/features/cart/actions/cart-actions.d.ts +16 -0
- package/dist/features/cart/actions/cart-actions.js +26 -12
- package/dist/features/cart/components/CartDrawer.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +132 -8
- package/dist/features/cart/schemas/firestore.js +22 -0
- package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
- package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
- package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
- package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
- package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
- package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
- package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
- package/dist/features/catalogue/schemas/firestore.js +12 -0
- package/dist/features/categories/schemas/index.d.ts +2 -2
- package/dist/features/email/index.d.ts +1 -1
- package/dist/features/email/index.js +5 -1
- package/dist/features/email/notification-templates.d.ts +35 -0
- package/dist/features/email/notification-templates.js +96 -0
- package/dist/features/messages/schemas/index.d.ts +6 -6
- package/dist/features/orders/schemas/index.d.ts +14 -14
- package/dist/features/payments/repository/payout.repository.d.ts +18 -5
- package/dist/features/payments/repository/payout.repository.js +45 -19
- package/dist/features/payments/schemas/firestore.d.ts +33 -1
- package/dist/features/payments/schemas/firestore.js +39 -0
- package/dist/features/payments/schemas/index.d.ts +12 -12
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/schemas/index.d.ts +12 -12
- package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
- package/dist/features/scams/repository/scammer.repository.js +24 -2
- package/dist/features/scams/schemas/firestore.d.ts +24 -0
- package/dist/features/scams/schemas/firestore.js +30 -0
- package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
- package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
- package/dist/features/shipments/repository/shipments.repository.js +23 -1
- package/dist/features/shipments/schemas/firestore.d.ts +14 -0
- package/dist/features/shipments/schemas/firestore.js +19 -0
- package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
- package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
- package/dist/features/status-history/index.d.ts +2 -0
- package/dist/features/status-history/index.js +1 -0
- package/dist/features/stores/repository/store.repository.d.ts +30 -2
- package/dist/features/stores/repository/store.repository.js +43 -4
- package/dist/features/stores/schemas/firestore.d.ts +29 -0
- package/dist/features/stores/schemas/firestore.js +31 -0
- package/dist/features/support/repository/support.repository.d.ts +47 -3
- package/dist/features/support/repository/support.repository.js +72 -12
- package/dist/features/support/schemas/firestore.d.ts +38 -1
- package/dist/features/support/schemas/firestore.js +34 -0
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +84 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +15 -2
- package/dist/schemas/registry.d.ts +34 -34
- package/dist/seed/bids-seed-data.js +8 -0
- package/dist/seed/factories/notification.factory.d.ts +2 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +5 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +10 -1
- package/dist/styles.css +58 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/QuantityStepper.d.ts +38 -0
- package/dist/ui/components/QuantityStepper.js +20 -0
- package/dist/ui/components/QuantityStepper.style.css +56 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
|
@@ -6,7 +6,8 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
6
6
|
*/
|
|
7
7
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
8
8
|
import { DatabaseError } from "../../../errors";
|
|
9
|
-
import { BID_COLLECTION, createBidId } from "../schemas";
|
|
9
|
+
import { BID_COLLECTION, BID_TRACKED_FIELDS, BID_HISTORY_PII_FIELDS, createBidId } from "../schemas";
|
|
10
|
+
import { withHistory } from "../../../_internal/shared/history/index";
|
|
10
11
|
import { encryptPiiFields, decryptPiiFields, BID_PII_FIELDS, } from "../../../security";
|
|
11
12
|
import { BID_FIELDS } from "../../../constants/field-names";
|
|
12
13
|
export class BidRepository extends BaseRepository {
|
|
@@ -115,12 +116,21 @@ export class BidRepository extends BaseRepository {
|
|
|
115
116
|
data: this.mapDoc(doc),
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
/*
|
|
120
|
+
* ── Batch mutators ─────────────────────────────────────────────────────
|
|
121
|
+
*
|
|
122
|
+
* These take a `DocumentReference` because settlement writes every bid on
|
|
123
|
+
* an auction in one Firestore batch. `prior` is the document from the SAME
|
|
124
|
+
* query that produced the ref — every caller has it (`activeBids` and
|
|
125
|
+
* `losers` are both `{ ref, data }` pairs), so history costs no extra read
|
|
126
|
+
* even when an auction closes with hundreds of bids (Rule #6).
|
|
127
|
+
*
|
|
128
|
+
* Omitting `prior` is legitimate rather than broken: the entry then records
|
|
129
|
+
* `undefined → "lost"`, which is still true and still stamped with who and
|
|
130
|
+
* when. It just cannot say what the bid was before.
|
|
131
|
+
*/
|
|
132
|
+
markWon(batch, ref, ctx, prior) {
|
|
133
|
+
batch.update(ref, this.withBidHistory(prior, { status: "won", isWinning: true, updatedAt: new Date() }, ctx, "markWon"));
|
|
124
134
|
}
|
|
125
135
|
/**
|
|
126
136
|
* Link a won bid to the order it eventually became.
|
|
@@ -130,23 +140,21 @@ export class BidRepository extends BaseRepository {
|
|
|
130
140
|
* /user/bids could not link anywhere, and nothing could tell a settled win
|
|
131
141
|
* from an unpaid one.
|
|
132
142
|
*/
|
|
133
|
-
async attachOrder(bidId, orderId) {
|
|
134
|
-
|
|
143
|
+
async attachOrder(bidId, orderId, ctx, prior) {
|
|
144
|
+
const current = prior !== undefined ? prior : await this.findById(bidId);
|
|
145
|
+
await this.update(bidId, this.withBidHistory(current, { orderId, updatedAt: new Date() }, ctx, "attachOrder"));
|
|
135
146
|
}
|
|
136
147
|
/**
|
|
137
148
|
* The winner never paid within their checkout window. Distinct from "lost"
|
|
138
149
|
* (outbid) — this bidder won and then defaulted, which a seller may want to
|
|
139
150
|
* act on.
|
|
140
151
|
*/
|
|
141
|
-
async markForfeited(bidId) {
|
|
142
|
-
|
|
152
|
+
async markForfeited(bidId, ctx, prior) {
|
|
153
|
+
const current = prior !== undefined ? prior : await this.findById(bidId);
|
|
154
|
+
await this.update(bidId, this.withBidHistory(current, { status: "forfeited", isWinning: false, updatedAt: new Date() }, ctx, "markForfeited"));
|
|
143
155
|
}
|
|
144
|
-
markLost(batch, ref) {
|
|
145
|
-
batch.update(ref, {
|
|
146
|
-
status: "lost",
|
|
147
|
-
isWinning: false,
|
|
148
|
-
updatedAt: new Date(),
|
|
149
|
-
});
|
|
156
|
+
markLost(batch, ref, ctx, prior) {
|
|
157
|
+
batch.update(ref, this.withBidHistory(prior, { status: "lost", isWinning: false, updatedAt: new Date() }, ctx, "markLost"));
|
|
150
158
|
}
|
|
151
159
|
/**
|
|
152
160
|
* Batched "everyone else lost", for callers that are not already inside a
|
|
@@ -154,12 +162,20 @@ export class BidRepository extends BaseRepository {
|
|
|
154
162
|
* settlement job, and the losing bids can be any number, so this writes them
|
|
155
163
|
* in Firestore-batch chunks rather than one round-trip per bid.
|
|
156
164
|
*/
|
|
157
|
-
async markManyLost(
|
|
165
|
+
async markManyLost(entries, ctx) {
|
|
158
166
|
const CHUNK = 400; // under Firestore's 500-write batch limit, with headroom
|
|
159
|
-
for (let i = 0; i <
|
|
167
|
+
for (let i = 0; i < entries.length; i += CHUNK) {
|
|
160
168
|
const batch = this.db.batch();
|
|
161
|
-
for (const
|
|
162
|
-
|
|
169
|
+
for (const entry of entries.slice(i, i + CHUNK)) {
|
|
170
|
+
// Accepts a bare ref OR a {ref, data} pair, so the caller that already
|
|
171
|
+
// holds the documents gets a real before-value on the timeline and the
|
|
172
|
+
// one that does not still works. Widened rather than made required
|
|
173
|
+
// because forcing a read per losing bid would put a large auction's
|
|
174
|
+
// settlement over the Firestore read budget.
|
|
175
|
+
const isPair = typeof entry === "object" && "ref" in entry;
|
|
176
|
+
const ref = isPair ? entry.ref : entry;
|
|
177
|
+
this.markLost(batch, ref, ctx, isPair ? entry.data : undefined);
|
|
178
|
+
}
|
|
163
179
|
await batch.commit();
|
|
164
180
|
}
|
|
165
181
|
}
|
|
@@ -168,21 +184,29 @@ export class BidRepository extends BaseRepository {
|
|
|
168
184
|
* status means "won, then defaulted", and a lapsed claim never won anything;
|
|
169
185
|
* the auction it was placed against carried on untouched.
|
|
170
186
|
*/
|
|
171
|
-
async markCancelled(bidId) {
|
|
172
|
-
|
|
187
|
+
async markCancelled(bidId, ctx, prior) {
|
|
188
|
+
const current = prior !== undefined ? prior : await this.findById(bidId);
|
|
189
|
+
await this.update(bidId, this.withBidHistory(current, { status: "cancelled", isWinning: false, updatedAt: new Date() }, ctx, "markCancelled"));
|
|
173
190
|
}
|
|
174
|
-
markOutbid(batch, ref) {
|
|
175
|
-
batch.update(ref, {
|
|
176
|
-
status: "outbid",
|
|
177
|
-
isWinning: false,
|
|
178
|
-
updatedAt: new Date(),
|
|
179
|
-
});
|
|
191
|
+
markOutbid(batch, ref, ctx, prior) {
|
|
192
|
+
batch.update(ref, this.withBidHistory(prior, { status: "outbid", isWinning: false, updatedAt: new Date() }, ctx, "markOutbid"));
|
|
180
193
|
}
|
|
181
|
-
markWinning(batch, ref) {
|
|
182
|
-
batch.update(ref, {
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
markWinning(batch, ref, ctx, prior) {
|
|
195
|
+
batch.update(ref, this.withBidHistory(prior, { isWinning: true, updatedAt: new Date() }, ctx, "markWinning"));
|
|
196
|
+
}
|
|
197
|
+
/** Append a timeline entry when the patch touches a tracked field. */
|
|
198
|
+
withBidHistory(current, patch, ctx, defaultTrigger) {
|
|
199
|
+
const withEntry = withHistory(current, patch, {
|
|
200
|
+
tracked: BID_TRACKED_FIELDS,
|
|
201
|
+
actor: ctx?.actor ?? { role: "system" },
|
|
202
|
+
trigger: ctx?.trigger ?? defaultTrigger,
|
|
203
|
+
reason: ctx?.reason,
|
|
204
|
+
note: ctx?.note,
|
|
205
|
+
// `userName`/`userEmail` are denormalised onto every bid, and
|
|
206
|
+
// `encryptPiiFields` never descends into arrays.
|
|
207
|
+
piiFields: BID_HISTORY_PII_FIELDS,
|
|
185
208
|
});
|
|
209
|
+
return withEntry ?? patch;
|
|
186
210
|
}
|
|
187
211
|
/**
|
|
188
212
|
* Find current winning bid for a product
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Bids/Auctions Firestore Document Types & Constants
|
|
3
3
|
*/
|
|
4
4
|
import { type GenerateBidIdInput } from "../../../utils/id-generators";
|
|
5
|
+
import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
|
|
5
6
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
6
7
|
export type BidStatus = "active" | "outbid" | "won" | "lost" | "cancelled"
|
|
7
8
|
/** Won, then the buyer let the 48h payment window lapse. */
|
|
@@ -52,6 +53,16 @@ export interface BidDocument extends BaseDocument {
|
|
|
52
53
|
* `claimAuctionForCheckout`, not by blocking the second buyer up front.
|
|
53
54
|
*/
|
|
54
55
|
isBuyout?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Who changed what, when, and why. See § "Status History" in CLAUDE.md.
|
|
58
|
+
*
|
|
59
|
+
* A bid moves through more states than any other record a buyer owns —
|
|
60
|
+
* active → outbid → active → won → forfeited, or cancelled for a lapsed
|
|
61
|
+
* buyout claim — and every one of those moves happened inside a batch with
|
|
62
|
+
* nothing recording it. "Why does this say forfeited" was unanswerable.
|
|
63
|
+
*/
|
|
64
|
+
statusHistory?: StatusChangeEntry[];
|
|
65
|
+
statusHistoryTruncated?: number;
|
|
55
66
|
}
|
|
56
67
|
export declare const BID_COLLECTION: "bids";
|
|
57
68
|
export declare const BID_INDEXED_FIELDS: readonly ["productId", "userId", "status", "isWinning", "bidDate", "createdAt"];
|
|
@@ -71,3 +82,19 @@ export declare const bidQueryHelpers: {
|
|
|
71
82
|
export declare function createBidId(input: Omit<GenerateBidIdInput, "random"> & {
|
|
72
83
|
random?: string;
|
|
73
84
|
}): string;
|
|
85
|
+
/**
|
|
86
|
+
* The fields whose changes earn a timeline entry.
|
|
87
|
+
*
|
|
88
|
+
* `bidAmount` is included because a proxy bid raises it in place, so the
|
|
89
|
+
* timeline is the only record of what the bidder actually committed at each
|
|
90
|
+
* step. `previousBidAmount` is excluded — it is the same information one
|
|
91
|
+
* entry earlier.
|
|
92
|
+
*/
|
|
93
|
+
export declare const BID_TRACKED_FIELDS: readonly ["status", "isWinning", "bidAmount", "orderId"];
|
|
94
|
+
/**
|
|
95
|
+
* PII on this document, for `withHistory`'s scrub. `userName` and `userEmail`
|
|
96
|
+
* are denormalised onto every bid, so a diff of an unrelated field would drag
|
|
97
|
+
* them into an array `encryptPiiFields` cannot descend into — the exact leak
|
|
98
|
+
* the primitive's `piiFields` exists to prevent.
|
|
99
|
+
*/
|
|
100
|
+
export declare const BID_HISTORY_PII_FIELDS: readonly ["userName", "userEmail"];
|
|
@@ -49,3 +49,24 @@ export const bidQueryHelpers = {
|
|
|
49
49
|
export function createBidId(input) {
|
|
50
50
|
return generateBidId(input);
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The fields whose changes earn a timeline entry.
|
|
54
|
+
*
|
|
55
|
+
* `bidAmount` is included because a proxy bid raises it in place, so the
|
|
56
|
+
* timeline is the only record of what the bidder actually committed at each
|
|
57
|
+
* step. `previousBidAmount` is excluded — it is the same information one
|
|
58
|
+
* entry earlier.
|
|
59
|
+
*/
|
|
60
|
+
export const BID_TRACKED_FIELDS = [
|
|
61
|
+
"status",
|
|
62
|
+
"isWinning",
|
|
63
|
+
"bidAmount",
|
|
64
|
+
"orderId",
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* PII on this document, for `withHistory`'s scrub. `userName` and `userEmail`
|
|
68
|
+
* are denormalised onto every bid, so a diff of an unrelated field would drag
|
|
69
|
+
* them into an array `encryptPiiFields` cannot descend into — the exact leak
|
|
70
|
+
* the primitive's `piiFields` exists to prevent.
|
|
71
|
+
*/
|
|
72
|
+
export const BID_HISTORY_PII_FIELDS = ["userName", "userEmail"];
|
|
@@ -65,14 +65,14 @@ export declare const authUserSchema: z.ZodObject<{
|
|
|
65
65
|
isEmailVerified: z.ZodOptional<z.ZodBoolean>;
|
|
66
66
|
}, "strip", z.ZodTypeAny, {
|
|
67
67
|
id: string;
|
|
68
|
-
role?: "user" | "
|
|
68
|
+
role?: "user" | "seller" | "admin" | "moderator" | "employee" | undefined;
|
|
69
69
|
email?: string | undefined;
|
|
70
70
|
displayName?: string | undefined;
|
|
71
71
|
photoURL?: string | undefined;
|
|
72
72
|
isEmailVerified?: boolean | undefined;
|
|
73
73
|
}, {
|
|
74
74
|
id: string;
|
|
75
|
-
role?: "user" | "
|
|
75
|
+
role?: "user" | "seller" | "admin" | "moderator" | "employee" | undefined;
|
|
76
76
|
email?: string | undefined;
|
|
77
77
|
displayName?: string | undefined;
|
|
78
78
|
photoURL?: string | undefined;
|
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* are handled by the calling server action in the consumer.
|
|
6
6
|
*/
|
|
7
7
|
import type { AddToCartInput, CartDocument, UpdateCartItemInput } from "../schemas";
|
|
8
|
+
/**
|
|
9
|
+
* Lane gate. While a higher-obligation lane (a won auction, then an accepted
|
|
10
|
+
* offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
|
|
11
|
+
* keep deferring something they already committed to by piling more into the
|
|
12
|
+
* cart. Enforced on the server, not only in the UI: the UI hint is a courtesy,
|
|
13
|
+
* this is the rule.
|
|
14
|
+
*
|
|
15
|
+
* 🛑 Every add-to-cart entry point must call this. It lived inline inside
|
|
16
|
+
* `addItemToCart` and therefore covered exactly one of the four ways a line can
|
|
17
|
+
* be created — `addBundleToCartAction` and `POST /api/cart` both call
|
|
18
|
+
* `cartRepository.addItem` directly and so bypassed it entirely, letting a buyer
|
|
19
|
+
* with an unpaid auction win keep shopping.
|
|
20
|
+
*
|
|
21
|
+
* Returns the loaded cart so the caller doesn't pay a second read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertCanAddNewItems(userId: string): Promise<CartDocument>;
|
|
8
24
|
export declare function addItemToCart(userId: string, input: AddToCartInput): Promise<CartDocument>;
|
|
9
25
|
/** Error code the client maps to a lane-aware message. */
|
|
10
26
|
export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
|
|
@@ -27,6 +27,31 @@ async function resolveStoreName(storeId, candidate) {
|
|
|
27
27
|
const store = await storeRepository.findById(storeId).catch(() => null);
|
|
28
28
|
return store?.storeName ?? storeId;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Lane gate. While a higher-obligation lane (a won auction, then an accepted
|
|
32
|
+
* offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
|
|
33
|
+
* keep deferring something they already committed to by piling more into the
|
|
34
|
+
* cart. Enforced on the server, not only in the UI: the UI hint is a courtesy,
|
|
35
|
+
* this is the rule.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 Every add-to-cart entry point must call this. It lived inline inside
|
|
38
|
+
* `addItemToCart` and therefore covered exactly one of the four ways a line can
|
|
39
|
+
* be created — `addBundleToCartAction` and `POST /api/cart` both call
|
|
40
|
+
* `cartRepository.addItem` directly and so bypassed it entirely, letting a buyer
|
|
41
|
+
* with an unpaid auction win keep shopping.
|
|
42
|
+
*
|
|
43
|
+
* Returns the loaded cart so the caller doesn't pay a second read.
|
|
44
|
+
*/
|
|
45
|
+
export async function assertCanAddNewItems(userId) {
|
|
46
|
+
const cart = await cartRepository.getOrCreate(userId);
|
|
47
|
+
if (!canAddNewItems(cart.items ?? [])) {
|
|
48
|
+
const blocking = activeLane(cart.items ?? []);
|
|
49
|
+
throw new ValidationError(blocking === CART_LANE.AUCTION
|
|
50
|
+
? "Settle your won auction first — you can add other items once it's paid for."
|
|
51
|
+
: "Complete your accepted offer first — you can add other items once it's paid for.", { code: CART_LANE_BLOCKED });
|
|
52
|
+
}
|
|
53
|
+
return cart;
|
|
54
|
+
}
|
|
30
55
|
export async function addItemToCart(userId, input) {
|
|
31
56
|
serverLogger.debug("addItemToCart", { userId, productId: input.productId });
|
|
32
57
|
// SB-UNI-F 2026-05-13 — capability gate. Classified + live listings reject
|
|
@@ -36,18 +61,7 @@ export async function addItemToCart(userId, input) {
|
|
|
36
61
|
if (!canAddToCart(input.listingType)) {
|
|
37
62
|
throw new ValidationError(`Listings of type "${input.listingType}" cannot be added to the cart.`);
|
|
38
63
|
}
|
|
39
|
-
|
|
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
|
-
}
|
|
64
|
+
await assertCanAddNewItems(userId);
|
|
51
65
|
const storeName = await resolveStoreName(input.storeId, input.storeName);
|
|
52
66
|
return cartRepository.addItem(userId, { ...input, storeName });
|
|
53
67
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { Aside, Button, Div, Heading, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
4
4
|
import { MediaImage } from "../../media/MediaImage";
|
|
5
|
+
import { QuantityStepper } from "../../../ui/components/QuantityStepper";
|
|
5
6
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
6
7
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
7
8
|
import { useHandMode } from "../../../_internal/client/hand-mode";
|
|
@@ -18,7 +19,7 @@ export function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock =
|
|
|
18
19
|
return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: `min-w-0 transition-opacity ${isOutOfStock ? "opacity-60" : ""}`, children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: item.meta.image && (_jsx(MediaImage, { src: item.meta.image, alt: item.meta.title, size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", className: "min-w-0", align: "start", children: [href ? (_jsx(TextLink, { href: href, target: "_blank", rel: "noopener noreferrer", weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: item.meta.title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.meta.title })), isOutOfStock && (_jsx(Span, { padding: "pill-2xs", color: "error", surface: "danger-surface", weight: "semibold", className: "flex-shrink-0 text-[10px] tracking-wide", rounded: "default", transform: "uppercase", children: "Out of Stock" }))] }), item.meta.attributes &&
|
|
19
20
|
Object.keys(item.meta.attributes).length > 0 && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", truncate: 1, children: Object.entries(item.meta.attributes)
|
|
20
21
|
.map(([k, v]) => `${k}: ${v}`)
|
|
21
|
-
.join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (
|
|
22
|
+
.join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsx(QuantityStepper, { value: item.quantity, onChange: (next) => onQtyChange(item.id, next), min: 1, ariaLabel: `Quantity for ${item.meta.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
|
|
22
23
|
}
|
|
23
24
|
export function CartDrawer({ isOpen, onClose, items, subtotal = 0, currency, isLoading, onQtyChange, onRemove, onCheckout, labels = {}, }) {
|
|
24
25
|
const { hand } = useHandMode();
|
|
@@ -6,6 +6,53 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
8
8
|
import type { ListingType } from "../../products/types/index";
|
|
9
|
+
/**
|
|
10
|
+
* What KIND of thing one cart line represents.
|
|
11
|
+
*
|
|
12
|
+
* - absent — an ordinary single-product line.
|
|
13
|
+
* - `"bundle"` — a fixed, all-or-nothing set priced as a whole
|
|
14
|
+
* (`categoryType:"bundle"` row on `categories`). Members are
|
|
15
|
+
* not pickable; the only stepper is the line-level one and it
|
|
16
|
+
* means COPIES OF THE BUNDLE.
|
|
17
|
+
* - `"group"` — a buyer-assembled selection out of a product group
|
|
18
|
+
* (`product.groupId`) or a `groupedListings` doc. Members ARE
|
|
19
|
+
* pickable; there is no line-level stepper.
|
|
20
|
+
*/
|
|
21
|
+
export type CartLineKind = "bundle" | "group";
|
|
22
|
+
/** Which collection a `"group"` line's members were picked from. */
|
|
23
|
+
export type CartGroupSource = "product-group" | "grouped-listing";
|
|
24
|
+
/**
|
|
25
|
+
* One member product inside a multi-member cart line.
|
|
26
|
+
*
|
|
27
|
+
* 🛑 THE QUANTITY INVARIANT — every reader and writer depends on it:
|
|
28
|
+
*
|
|
29
|
+
* `CartItemDocument.quantity` = copies of the whole selection
|
|
30
|
+
* `CartLineMember.quantity` = units of THIS member per copy
|
|
31
|
+
* demand for a product = item.quantity × member.quantity
|
|
32
|
+
*
|
|
33
|
+
* A `"group"` line pins `item.quantity` to 1, which is what lets `unitPriceFor`
|
|
34
|
+
* keep returning a UNIT price that every existing call site multiplies by
|
|
35
|
+
* `item.quantity` without double-counting. A `"bundle"` line is the mirror
|
|
36
|
+
* image: `item.quantity` is the editable one and member quantities are frozen
|
|
37
|
+
* at add time.
|
|
38
|
+
*
|
|
39
|
+
* Prices/titles/images are SNAPSHOTS taken server-side at add time. They are
|
|
40
|
+
* never accepted from the client — see `POST /api/cart/group`, which takes ids
|
|
41
|
+
* and quantities only.
|
|
42
|
+
*/
|
|
43
|
+
export interface CartLineMember {
|
|
44
|
+
productId: string;
|
|
45
|
+
/** Units of this member per copy of the line. See the invariant above. */
|
|
46
|
+
quantity: number;
|
|
47
|
+
/** Snapshot of the member's own price at add time, in decimal rupees. */
|
|
48
|
+
unitPrice: number;
|
|
49
|
+
title: string;
|
|
50
|
+
image?: string;
|
|
51
|
+
/** Snapshot for per-member GST proration at checkout (`lineTaxComponentsFor`). */
|
|
52
|
+
gstRate?: number;
|
|
53
|
+
hsnCode?: string;
|
|
54
|
+
listingType?: ListingType;
|
|
55
|
+
}
|
|
9
56
|
export interface CartItemDocument {
|
|
10
57
|
itemId: string;
|
|
11
58
|
productId: string;
|
|
@@ -64,16 +111,47 @@ export interface CartItemDocument {
|
|
|
64
111
|
/**
|
|
65
112
|
* SB-UNI-4 2026-05-13 — bundle identifier when this cart line represents a
|
|
66
113
|
* bundle (categoryType:"bundle" row on the categories collection). When set,
|
|
67
|
-
* `productId` points at the bundle category's id
|
|
68
|
-
* bundlePrice
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* the "Add to cart coming soon" notice up.
|
|
114
|
+
* `productId` points at the bundle category's id and `price` is the locked
|
|
115
|
+
* bundlePrice.
|
|
116
|
+
*
|
|
117
|
+
* This is an IDENTITY, not a mirror — it is what the order-side collapse key
|
|
118
|
+
* (`groupOrderItemsByLine`) groups on, and it keeps being written.
|
|
73
119
|
*/
|
|
74
120
|
bundleCategorySlug?: string;
|
|
75
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* @deprecated Legacy carts only. Superseded by `groupMembers`, which can
|
|
123
|
+
* express per-member quantities where a bare id array cannot.
|
|
124
|
+
*
|
|
125
|
+
* Do NOT write this from new code and do NOT read it directly — a
|
|
126
|
+
* hand-maintained mirror alongside `groupMembers` is exactly the drift
|
|
127
|
+
* Recurrent Root Cause #42 describes. Read through `getCartLineMembers()`
|
|
128
|
+
* (`_internal/server/features/checkout/bundle-expansion.ts`), which falls
|
|
129
|
+
* back to these ids at quantity 1 so lines written before `groupMembers`
|
|
130
|
+
* existed keep behaving byte-identically.
|
|
131
|
+
*/
|
|
76
132
|
bundleProductIds?: string[];
|
|
133
|
+
/**
|
|
134
|
+
* Discriminator for a multi-member line. Absent on an ordinary
|
|
135
|
+
* single-product line. See `CartLineKind` for the two behaviours.
|
|
136
|
+
*/
|
|
137
|
+
lineKind?: CartLineKind;
|
|
138
|
+
/** Which collection the members came from. `"group"` lines only. */
|
|
139
|
+
groupSource?: CartGroupSource;
|
|
140
|
+
/** `product.groupId` | `groupedListing.id` | the bundle category's id. */
|
|
141
|
+
groupId?: string;
|
|
142
|
+
/** Slug for the line's detail href. Bundles use `bundleCategorySlug` instead. */
|
|
143
|
+
groupSlug?: string;
|
|
144
|
+
/** Display snapshot of the group/bundle name. */
|
|
145
|
+
groupTitle?: string;
|
|
146
|
+
/**
|
|
147
|
+
* The members of a multi-member line, AUTHORITATIVE over `bundleProductIds`.
|
|
148
|
+
* See `CartLineMember` for the quantity invariant.
|
|
149
|
+
*
|
|
150
|
+
* `price` on this document is derived from this array by the repository
|
|
151
|
+
* (`deriveLinePrice`) at every write, never set by a caller — so no caller
|
|
152
|
+
* can forget to keep the two in step.
|
|
153
|
+
*/
|
|
154
|
+
groupMembers?: CartLineMember[];
|
|
77
155
|
/**
|
|
78
156
|
* S-SBUNI-RULES 2026-05-13 — buyer's chosen shipping provider for this
|
|
79
157
|
* item, snapshotted at cart time. Locked at checkout; the per-tab
|
|
@@ -149,10 +227,32 @@ export declare const CART_FIELDS: {
|
|
|
149
227
|
readonly STORE_ID: "storeId";
|
|
150
228
|
readonly STORE_NAME: "storeName";
|
|
151
229
|
readonly LISTING_TYPE: "listingType";
|
|
230
|
+
readonly IS_OFFER: "isOffer";
|
|
231
|
+
readonly OFFER_ID: "offerId";
|
|
232
|
+
readonly IS_AUCTION_WIN: "isAuctionWin";
|
|
233
|
+
readonly AUCTION_ID: "auctionId";
|
|
234
|
+
readonly BID_ID: "bidId";
|
|
235
|
+
readonly IS_BUYOUT: "isBuyout";
|
|
236
|
+
readonly LOCKED_PRICE: "lockedPrice";
|
|
237
|
+
readonly CHECKOUT_DEADLINE: "checkoutDeadline";
|
|
238
|
+
readonly LOCKED: "locked";
|
|
239
|
+
readonly BUNDLE_CATEGORY_SLUG: "bundleCategorySlug";
|
|
240
|
+
/** @deprecated legacy read path — see CartItemDocument.bundleProductIds */
|
|
241
|
+
readonly BUNDLE_PRODUCT_IDS: "bundleProductIds";
|
|
242
|
+
readonly LINE_KIND: "lineKind";
|
|
243
|
+
readonly GROUP_SOURCE: "groupSource";
|
|
244
|
+
readonly GROUP_ID: "groupId";
|
|
245
|
+
readonly GROUP_SLUG: "groupSlug";
|
|
246
|
+
readonly GROUP_TITLE: "groupTitle";
|
|
247
|
+
readonly GROUP_MEMBERS: "groupMembers";
|
|
248
|
+
readonly CHOSEN_SHIPPING_PROVIDER_ID: "chosenShippingProviderId";
|
|
249
|
+
readonly CHOSEN_SHIPPING_FEE: "chosenShippingFee";
|
|
152
250
|
readonly ADDED_AT: "addedAt";
|
|
153
251
|
readonly UPDATED_AT: "updatedAt";
|
|
154
252
|
};
|
|
155
253
|
};
|
|
254
|
+
export declare const CART_LINE_KIND_VALUES: readonly ["bundle", "group"];
|
|
255
|
+
export declare const CART_GROUP_SOURCE_VALUES: readonly ["product-group", "grouped-listing"];
|
|
156
256
|
export declare const DEFAULT_CART_DATA: Partial<CartDocument>;
|
|
157
257
|
export type CartCreateInput = Omit<CartDocument, "id" | "createdAt" | "updatedAt">;
|
|
158
258
|
export type AddToCartInput = {
|
|
@@ -178,12 +278,36 @@ export type AddToCartInput = {
|
|
|
178
278
|
locked?: boolean;
|
|
179
279
|
/** SB-UNI-4 2026-05-13 — bundle identifier when the line is a bundle. */
|
|
180
280
|
bundleCategorySlug?: string;
|
|
181
|
-
/**
|
|
281
|
+
/** @deprecated Legacy only — write `groupMembers` instead. */
|
|
182
282
|
bundleProductIds?: string[];
|
|
283
|
+
lineKind?: CartLineKind;
|
|
284
|
+
groupSource?: CartGroupSource;
|
|
285
|
+
groupId?: string;
|
|
286
|
+
groupSlug?: string;
|
|
287
|
+
groupTitle?: string;
|
|
288
|
+
groupMembers?: CartLineMember[];
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Input for creating a multi-member line. `price` is deliberately absent — the
|
|
292
|
+
* repository derives it from `groupMembers` so the two can never disagree.
|
|
293
|
+
*/
|
|
294
|
+
export type AddGroupLineInput = Omit<AddToCartInput, "price" | "groupMembers"> & {
|
|
295
|
+
groupMembers: CartLineMember[];
|
|
183
296
|
};
|
|
184
297
|
export type UpdateCartItemInput = {
|
|
185
298
|
quantity: number;
|
|
186
299
|
};
|
|
300
|
+
/**
|
|
301
|
+
* Body of a per-member edit. The WHOLE array is sent, not a delta: the cart is
|
|
302
|
+
* a single Firestore document written with `set()`, so N per-member writes
|
|
303
|
+
* would be both a Rule #6 cost and a real lost-update race on a double-click.
|
|
304
|
+
*/
|
|
305
|
+
export type UpdateCartGroupMembersInput = {
|
|
306
|
+
groupMembers: Array<{
|
|
307
|
+
productId: string;
|
|
308
|
+
quantity: number;
|
|
309
|
+
}>;
|
|
310
|
+
};
|
|
187
311
|
export declare const cartQueryHelpers: {
|
|
188
312
|
readonly byUserId: (userId: string) => {
|
|
189
313
|
field: "userId";
|
|
@@ -23,10 +23,32 @@ export const CART_FIELDS = {
|
|
|
23
23
|
STORE_ID: "storeId",
|
|
24
24
|
STORE_NAME: "storeName",
|
|
25
25
|
LISTING_TYPE: "listingType",
|
|
26
|
+
IS_OFFER: "isOffer",
|
|
27
|
+
OFFER_ID: "offerId",
|
|
28
|
+
IS_AUCTION_WIN: "isAuctionWin",
|
|
29
|
+
AUCTION_ID: "auctionId",
|
|
30
|
+
BID_ID: "bidId",
|
|
31
|
+
IS_BUYOUT: "isBuyout",
|
|
32
|
+
LOCKED_PRICE: "lockedPrice",
|
|
33
|
+
CHECKOUT_DEADLINE: "checkoutDeadline",
|
|
34
|
+
LOCKED: "locked",
|
|
35
|
+
BUNDLE_CATEGORY_SLUG: "bundleCategorySlug",
|
|
36
|
+
/** @deprecated legacy read path — see CartItemDocument.bundleProductIds */
|
|
37
|
+
BUNDLE_PRODUCT_IDS: "bundleProductIds",
|
|
38
|
+
LINE_KIND: "lineKind",
|
|
39
|
+
GROUP_SOURCE: "groupSource",
|
|
40
|
+
GROUP_ID: "groupId",
|
|
41
|
+
GROUP_SLUG: "groupSlug",
|
|
42
|
+
GROUP_TITLE: "groupTitle",
|
|
43
|
+
GROUP_MEMBERS: "groupMembers",
|
|
44
|
+
CHOSEN_SHIPPING_PROVIDER_ID: "chosenShippingProviderId",
|
|
45
|
+
CHOSEN_SHIPPING_FEE: "chosenShippingFee",
|
|
26
46
|
ADDED_AT: "addedAt",
|
|
27
47
|
UPDATED_AT: "updatedAt",
|
|
28
48
|
},
|
|
29
49
|
};
|
|
50
|
+
export const CART_LINE_KIND_VALUES = ["bundle", "group"];
|
|
51
|
+
export const CART_GROUP_SOURCE_VALUES = ["product-group", "grouped-listing"];
|
|
30
52
|
export const DEFAULT_CART_DATA = {
|
|
31
53
|
items: [],
|
|
32
54
|
};
|
|
@@ -23,11 +23,12 @@ export async function approveCatalogueListingAction(itemId) {
|
|
|
23
23
|
throw new ValidationError("Only items pending approval can be approved");
|
|
24
24
|
}
|
|
25
25
|
const product = await createProductFromCatalogueItem(item, CONSIGNMENT_STORE_ID);
|
|
26
|
-
|
|
26
|
+
// `item` came from the findById above, so the timeline entry is free.
|
|
27
|
+
await catalogueRepository.setListingStatus(itemId, {
|
|
27
28
|
listingStatus: "listed",
|
|
28
29
|
linkedProductId: product.id,
|
|
29
30
|
linkedProductSlug: product.slug ?? product.id,
|
|
30
|
-
});
|
|
31
|
+
}, { actor: { role: "admin", uid: user.uid }, trigger: "approveCatalogueListing" }, item);
|
|
31
32
|
return { productId: product.id, productSlug: product.slug ?? product.id };
|
|
32
33
|
});
|
|
33
34
|
}
|
|
@@ -42,9 +43,9 @@ export async function rejectCatalogueListingAction(itemId, reason) {
|
|
|
42
43
|
if (item.listingStatus !== "pending_admin_approval") {
|
|
43
44
|
throw new ValidationError("Only items pending approval can be rejected");
|
|
44
45
|
}
|
|
45
|
-
await catalogueRepository.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
46
|
+
await catalogueRepository.setListingStatus(itemId, { listingStatus: "rejected", rejectionReason: reason },
|
|
47
|
+
// The reason is the point of the entry — `rejectionReason` on the
|
|
48
|
+
// document is overwritten by the next decision.
|
|
49
|
+
{ actor: { role: "admin", uid: user.uid }, trigger: "rejectCatalogueListing", reason }, item);
|
|
49
50
|
});
|
|
50
51
|
}
|
|
@@ -40,11 +40,11 @@ export async function listFromCatalogueAction(itemId) {
|
|
|
40
40
|
storeId = store.id;
|
|
41
41
|
}
|
|
42
42
|
const product = await createProductFromCatalogueItem(item, storeId);
|
|
43
|
-
await catalogueRepository.
|
|
43
|
+
await catalogueRepository.setListingStatus(itemId, {
|
|
44
44
|
listingStatus: "listed",
|
|
45
45
|
linkedProductId: product.id,
|
|
46
46
|
linkedProductSlug: product.slug ?? product.id,
|
|
47
|
-
});
|
|
47
|
+
}, { actor: { role: "seller", uid: user.uid }, trigger: "listFromCatalogue" }, item);
|
|
48
48
|
return { productId: product.id, productSlug: product.slug ?? product.id };
|
|
49
49
|
});
|
|
50
50
|
}
|
|
@@ -22,9 +22,6 @@ export async function submitCatalogueItemForApprovalAction(itemId) {
|
|
|
22
22
|
throw new ValidationError("Only items not yet submitted can be requested for listing");
|
|
23
23
|
}
|
|
24
24
|
assertCatalogueImagesFresh(item);
|
|
25
|
-
await catalogueRepository.
|
|
26
|
-
listingStatus: "pending_admin_approval",
|
|
27
|
-
submittedForApprovalAt: new Date(),
|
|
28
|
-
});
|
|
25
|
+
await catalogueRepository.setListingStatus(itemId, { listingStatus: "pending_admin_approval", submittedForApprovalAt: new Date() }, { actor: { role: "buyer", uid: user.uid }, trigger: "submitCatalogueForApproval" }, item);
|
|
29
26
|
});
|
|
30
27
|
}
|
|
@@ -10,6 +10,7 @@ import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
|
10
10
|
import { apiClient } from "../../../http";
|
|
11
11
|
import { toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData";
|
|
12
12
|
import { DataListingView } from "../../admin/components/DataListingView";
|
|
13
|
+
import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
|
|
13
14
|
/** The submitted item's photos — the whole point of a visual approval. */
|
|
14
15
|
function toPhotoEntries(raw) {
|
|
15
16
|
const images = Array.isArray(raw.images) ? raw.images : [];
|
|
@@ -71,7 +72,10 @@ export function AdminCatalogueApprovalsView() {
|
|
|
71
72
|
{ label: "Quantity", value: String(raw.quantity ?? 1) },
|
|
72
73
|
{ label: "Condition", value: toStringValue(raw.condition, "—") },
|
|
73
74
|
{ label: "Submitted", value: selected?.updatedAt ?? "—" },
|
|
74
|
-
], items: { heading: "Photos", entries: toPhotoEntries(raw) },
|
|
75
|
+
], items: { heading: "Photos", entries: toPhotoEntries(raw) }, extra:
|
|
76
|
+
// "Why was mine rejected and by whom" — `rejectionReason` alone is
|
|
77
|
+
// overwritten by the next decision.
|
|
78
|
+
_jsx(RecordStatusTimeline, { entries: raw.statusHistory, truncatedCount: raw.statusHistoryTruncated }), footer: selected ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "danger", onClick: () => {
|
|
75
79
|
setRejectTarget(selected.id);
|
|
76
80
|
setSelected(null);
|
|
77
81
|
}, children: "Reject" }), _jsx(Button, { isLoading: approveMutation.isPending, onClick: () => {
|