@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.
Files changed (112) hide show
  1. package/dist/_internal/server/features/bundles/actions.js +7 -0
  2. package/dist/_internal/server/features/checkout/actions.js +24 -16
  3. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  4. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  5. package/dist/_internal/server/features/stores/adapters.js +5 -0
  6. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  7. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  8. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  9. package/dist/_internal/shared/actions/action-registry.js +41 -0
  10. package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
  11. package/dist/_internal/shared/checkout/order-math.js +29 -6
  12. package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
  13. package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
  14. package/dist/_internal/shared/features/cart/config.d.ts +2 -1
  15. package/dist/_internal/shared/features/cart/config.js +2 -1
  16. package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
  17. package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
  18. package/dist/_internal/shared/features/notifications/action-url.js +111 -0
  19. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  20. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  21. package/dist/_internal/shared/history/index.d.ts +1 -1
  22. package/dist/_internal/shared/history/types.d.ts +16 -0
  23. package/dist/client.d.ts +2 -0
  24. package/dist/client.js +1 -0
  25. package/dist/constants/field-names.d.ts +12 -0
  26. package/dist/constants/field-names.js +12 -0
  27. package/dist/constants/notification-types.d.ts +38 -10
  28. package/dist/constants/notification-types.js +39 -28
  29. package/dist/features/account/types/index.d.ts +1 -10
  30. package/dist/features/admin/actions/admin-actions.js +13 -3
  31. package/dist/features/admin/actions/notification-actions.d.ts +12 -0
  32. package/dist/features/admin/actions/notification-actions.js +149 -4
  33. package/dist/features/admin/components/AdminBidsView.js +7 -1
  34. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  35. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  36. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  37. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  38. package/dist/features/admin/schemas/firestore.d.ts +14 -3
  39. package/dist/features/admin/schemas/firestore.js +79 -0
  40. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  41. package/dist/features/auctions/repository/bid.repository.js +57 -33
  42. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  43. package/dist/features/auctions/schemas/firestore.js +21 -0
  44. package/dist/features/auth/schemas/index.d.ts +2 -2
  45. package/dist/features/cart/actions/cart-actions.d.ts +16 -0
  46. package/dist/features/cart/actions/cart-actions.js +26 -12
  47. package/dist/features/cart/components/CartDrawer.js +2 -1
  48. package/dist/features/cart/schemas/firestore.d.ts +132 -8
  49. package/dist/features/cart/schemas/firestore.js +22 -0
  50. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  51. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  52. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  53. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  54. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  55. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  56. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  57. package/dist/features/catalogue/schemas/firestore.js +12 -0
  58. package/dist/features/categories/schemas/index.d.ts +2 -2
  59. package/dist/features/email/index.d.ts +1 -1
  60. package/dist/features/email/index.js +5 -1
  61. package/dist/features/email/notification-templates.d.ts +35 -0
  62. package/dist/features/email/notification-templates.js +96 -0
  63. package/dist/features/messages/schemas/index.d.ts +6 -6
  64. package/dist/features/orders/schemas/index.d.ts +14 -14
  65. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  66. package/dist/features/payments/repository/payout.repository.js +45 -19
  67. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  68. package/dist/features/payments/schemas/firestore.js +39 -0
  69. package/dist/features/payments/schemas/index.d.ts +12 -12
  70. package/dist/features/products/schemas/index.d.ts +2 -2
  71. package/dist/features/promotions/schemas/index.d.ts +12 -12
  72. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  73. package/dist/features/scams/repository/scammer.repository.js +24 -2
  74. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  75. package/dist/features/scams/schemas/firestore.js +30 -0
  76. package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
  77. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  78. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  79. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  80. package/dist/features/shipments/schemas/firestore.js +19 -0
  81. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  82. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  83. package/dist/features/status-history/index.d.ts +2 -0
  84. package/dist/features/status-history/index.js +1 -0
  85. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  86. package/dist/features/stores/repository/store.repository.js +43 -4
  87. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  88. package/dist/features/stores/schemas/firestore.js +31 -0
  89. package/dist/features/support/repository/support.repository.d.ts +47 -3
  90. package/dist/features/support/repository/support.repository.js +72 -12
  91. package/dist/features/support/schemas/firestore.d.ts +38 -1
  92. package/dist/features/support/schemas/firestore.js +34 -0
  93. package/dist/features/support/schemas/index.d.ts +2 -2
  94. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +84 -0
  95. package/dist/index.d.ts +7 -4
  96. package/dist/index.js +15 -2
  97. package/dist/schemas/registry.d.ts +34 -34
  98. package/dist/seed/bids-seed-data.js +8 -0
  99. package/dist/seed/factories/notification.factory.d.ts +2 -1
  100. package/dist/server-entry.d.ts +1 -0
  101. package/dist/server-entry.js +5 -0
  102. package/dist/server.d.ts +5 -1
  103. package/dist/server.js +10 -1
  104. package/dist/styles.css +58 -1
  105. package/dist/tailwind-utilities.css +1 -1
  106. package/dist/ui/components/QuantityStepper.d.ts +38 -0
  107. package/dist/ui/components/QuantityStepper.js +20 -0
  108. package/dist/ui/components/QuantityStepper.style.css +56 -0
  109. package/dist/ui/components/index.style.css +1 -0
  110. package/dist/ui/index.d.ts +2 -0
  111. package/dist/ui/index.js +1 -0
  112. 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
- markWon(batch, ref) {
119
- batch.update(ref, {
120
- status: "won",
121
- isWinning: true,
122
- updatedAt: new Date(),
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
- await this.update(bidId, { orderId, updatedAt: new Date() });
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
- await this.update(bidId, { status: "forfeited", isWinning: false, updatedAt: new Date() });
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(refs) {
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 < refs.length; i += CHUNK) {
167
+ for (let i = 0; i < entries.length; i += CHUNK) {
160
168
  const batch = this.db.batch();
161
- for (const ref of refs.slice(i, i + CHUNK))
162
- this.markLost(batch, ref);
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
- await this.update(bidId, { status: "cancelled", isWinning: false, updatedAt: new Date() });
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
- isWinning: true,
184
- updatedAt: new Date(),
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" | "admin" | "moderator" | "seller" | "employee" | undefined;
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" | "admin" | "moderator" | "seller" | "employee" | undefined;
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
- // 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
- }
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 && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity - 1), disabled: item.quantity <= 1, variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] disabled:opacity-40", children: "\u2212" }), _jsx(Span, { size: "sm", className: "min-w-[1.5rem]", align: "center", children: item.quantity }), _jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity + 1), variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)]", children: "+" })] })), 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
+ .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, `price` is the locked
68
- * bundlePrice, and `bundleProductIds` snapshots the member product
69
- * ids at add-to-cart time. Order-side fan-out into per-product OrderItem
70
- * entries lands in S-SBUNI-5 (checkout-side stock decrement + per-store
71
- * grouping); until then the foundation is here but BundleDetailView keeps
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
- /** Snapshot of `bundle.bundleProductIds` at add-to-cart time. */
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
- /** Snapshot of bundle members at add-to-cart time. */
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
- await catalogueRepository.update(itemId, {
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.update(itemId, {
46
- listingStatus: "rejected",
47
- rejectionReason: reason,
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.update(itemId, {
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.update(itemId, {
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) }, footer: selected ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "danger", onClick: () => {
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: () => {