@mohasinac/appkit 4.24.0 → 4.26.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 (75) hide show
  1. package/dist/_internal/server/features/bundles/actions.d.ts +4 -1
  2. package/dist/_internal/server/features/bundles/actions.js +54 -5
  3. package/dist/_internal/server/features/checkout/actions.js +44 -73
  4. package/dist/_internal/server/features/checkout/bundle-expansion.d.ts +4 -6
  5. package/dist/_internal/server/features/checkout/bundle-expansion.js +19 -17
  6. package/dist/_internal/server/features/checkout/expand-order-items.d.ts +35 -0
  7. package/dist/_internal/server/features/checkout/expand-order-items.js +85 -0
  8. package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
  9. package/dist/_internal/server/features/grouped/index.d.ts +2 -0
  10. package/dist/_internal/server/features/grouped/index.js +2 -0
  11. package/dist/_internal/server/features/grouped/metadata.d.ts +17 -0
  12. package/dist/_internal/server/features/grouped/metadata.js +48 -0
  13. package/dist/_internal/server/features/grouped/og.d.ts +16 -0
  14. package/dist/_internal/server/features/grouped/og.js +24 -0
  15. package/dist/_internal/server/features/products/group-members.d.ts +55 -0
  16. package/dist/_internal/server/features/products/group-members.js +41 -0
  17. package/dist/_internal/server/features/seo/sitemap.js +24 -1
  18. package/dist/_internal/shared/checkout/line-members.d.ts +60 -0
  19. package/dist/_internal/shared/checkout/line-members.js +68 -0
  20. package/dist/_internal/shared/checkout/order-math.d.ts +63 -4
  21. package/dist/_internal/shared/checkout/order-math.js +119 -7
  22. package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
  23. package/dist/client.d.ts +9 -3
  24. package/dist/client.js +4 -2
  25. package/dist/constants/api-endpoints.d.ts +21 -0
  26. package/dist/constants/api-endpoints.js +7 -0
  27. package/dist/features/admin/schemas/firestore.d.ts +10 -26
  28. package/dist/features/admin/schemas/firestore.js +10 -26
  29. package/dist/features/analytics/types.d.ts +1 -1
  30. package/dist/features/analytics/types.js +3 -0
  31. package/dist/features/cart/actions/cart-actions.d.ts +31 -1
  32. package/dist/features/cart/actions/cart-actions.js +163 -0
  33. package/dist/features/cart/components/CartGroupLineRow.d.ts +42 -0
  34. package/dist/features/cart/components/CartGroupLineRow.js +33 -0
  35. package/dist/features/cart/components/index.d.ts +2 -0
  36. package/dist/features/cart/components/index.js +1 -0
  37. package/dist/features/cart/repository/cart.repository.d.ts +33 -1
  38. package/dist/features/cart/repository/cart.repository.js +152 -2
  39. package/dist/features/cart/schemas/firestore.d.ts +7 -2
  40. package/dist/features/categories/components/BundleBuyNowCta.d.ts +18 -5
  41. package/dist/features/categories/components/BundleBuyNowCta.js +24 -10
  42. package/dist/features/categories/components/BundleDetailView.d.ts +8 -1
  43. package/dist/features/categories/components/BundleDetailView.js +2 -2
  44. package/dist/features/categories/components/BundlesListView.d.ts +1 -0
  45. package/dist/features/categories/components/CategoryBundlesListing.d.ts +3 -0
  46. package/dist/features/grouped/components/GroupedListingDetailView.d.ts +21 -0
  47. package/dist/features/grouped/components/GroupedListingDetailView.js +17 -0
  48. package/dist/features/grouped/components/GroupedListingsCarousel.js +6 -1
  49. package/dist/features/orders/index.d.ts +1 -1
  50. package/dist/features/orders/index.js +1 -1
  51. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  52. package/dist/features/orders/utils/bundle-grouping.d.ts +29 -19
  53. package/dist/features/orders/utils/bundle-grouping.js +32 -22
  54. package/dist/features/products/components/GroupMemberPicker.d.ts +46 -0
  55. package/dist/features/products/components/GroupMemberPicker.js +108 -0
  56. package/dist/features/products/components/ShowGroupSection.js +7 -9
  57. package/dist/features/tester/seed-data/categories-tester-seed-data.js +6 -0
  58. package/dist/features/tester/seed-data/products-tester-seed-data.js +44 -1
  59. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +33 -0
  60. package/dist/index.d.ts +3 -2
  61. package/dist/index.js +6 -2
  62. package/dist/next/routing/route-map.d.ts +12 -0
  63. package/dist/next/routing/route-map.js +6 -0
  64. package/dist/seed/notifications-seed-data.js +225 -0
  65. package/dist/seed/payouts-seed-data.js +47 -0
  66. package/dist/seed/scammers-seed-data.js +34 -0
  67. package/dist/seed/stores-seed-data.js +34 -0
  68. package/dist/seed/support-tickets-seed-data.js +34 -0
  69. package/dist/server-entry.d.ts +1 -1
  70. package/dist/server-entry.js +1 -1
  71. package/dist/server.d.ts +3 -1
  72. package/dist/server.js +5 -1
  73. package/dist/styles.css +1 -1
  74. package/dist/tailwind-utilities.css +1 -1
  75. package/package.json +1 -1
@@ -239,6 +239,13 @@ export declare const CART_ENDPOINTS: {
239
239
  /** Per-store paid add-on selections — the source of truth for what's charged. */
240
240
  readonly ADDONS: "/api/cart/addons";
241
241
  readonly COUPON: "/api/cart/coupon";
242
+ /**
243
+ * Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
244
+ * POST on purpose: that is the hot path behind every product card's
245
+ * Add-to-cart, and widening its `{productId, quantity}` schema into a
246
+ * discriminated union would put this feature's blast radius on it.
247
+ */
248
+ readonly GROUP: "/api/cart/group";
242
249
  };
243
250
  export declare const CATEGORY_ENDPOINTS: {
244
251
  readonly LIST: "/api/categories";
@@ -695,6 +702,13 @@ export declare const API_ENDPOINTS: {
695
702
  /** Per-store paid add-on selections — the source of truth for what's charged. */
696
703
  readonly ADDONS: "/api/cart/addons";
697
704
  readonly COUPON: "/api/cart/coupon";
705
+ /**
706
+ * Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
707
+ * POST on purpose: that is the hot path behind every product card's
708
+ * Add-to-cart, and widening its `{productId, quantity}` schema into a
709
+ * discriminated union would put this feature's blast radius on it.
710
+ */
711
+ readonly GROUP: "/api/cart/group";
698
712
  };
699
713
  readonly CATEGORIES: {
700
714
  readonly LIST: "/api/categories";
@@ -1153,6 +1167,13 @@ export declare const API_ROUTES: {
1153
1167
  /** Per-store paid add-on selections — the source of truth for what's charged. */
1154
1168
  readonly ADDONS: "/api/cart/addons";
1155
1169
  readonly COUPON: "/api/cart/coupon";
1170
+ /**
1171
+ * Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
1172
+ * POST on purpose: that is the hot path behind every product card's
1173
+ * Add-to-cart, and widening its `{productId, quantity}` schema into a
1174
+ * discriminated union would put this feature's blast radius on it.
1175
+ */
1176
+ readonly GROUP: "/api/cart/group";
1156
1177
  };
1157
1178
  readonly CATEGORIES: {
1158
1179
  readonly LIST: "/api/categories";
@@ -279,6 +279,13 @@ export const CART_ENDPOINTS = {
279
279
  /** Per-store paid add-on selections — the source of truth for what's charged. */
280
280
  ADDONS: "/api/cart/addons",
281
281
  COUPON: "/api/cart/coupon",
282
+ /**
283
+ * Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
284
+ * POST on purpose: that is the hot path behind every product card's
285
+ * Add-to-cart, and widening its `{productId, quantity}` schema into a
286
+ * discriminated union would put this feature's blast radius on it.
287
+ */
288
+ GROUP: "/api/cart/group",
282
289
  };
283
290
  // ---------------------------------------------------------------------------
284
291
  // Categories
@@ -54,32 +54,16 @@ export declare const NOTIFICATION_FIELDS: {
54
54
  readonly RELATED_TYPE: "relatedType";
55
55
  readonly CREATED_AT: "createdAt";
56
56
  readonly UPDATED_AT: "updatedAt";
57
- readonly TYPE_VALUES: {
58
- readonly ORDER_PLACED: NotificationType;
59
- readonly ORDER_CONFIRMED: NotificationType;
60
- readonly ORDER_SHIPPED: NotificationType;
61
- readonly ORDER_DELIVERED: NotificationType;
62
- readonly ORDER_CANCELLED: NotificationType;
63
- readonly BID_PLACED: NotificationType;
64
- readonly BID_OUTBID: NotificationType;
65
- readonly BID_WON: NotificationType;
66
- readonly BID_LOST: NotificationType;
67
- readonly AUCTION_ENDED: NotificationType;
68
- readonly REVIEW_APPROVED: NotificationType;
69
- readonly REVIEW_REPLIED: NotificationType;
70
- readonly PRODUCT_AVAILABLE: NotificationType;
71
- readonly PROMOTION: NotificationType;
72
- readonly SYSTEM: NotificationType;
73
- readonly WELCOME: NotificationType;
74
- readonly OFFER_RECEIVED: NotificationType;
75
- readonly OFFER_RESPONDED: NotificationType;
76
- readonly OFFER_EXPIRED: NotificationType;
77
- readonly OFFER_COUNTER_ACCEPTED: NotificationType;
78
- readonly REFUND_INITIATED: NotificationType;
79
- readonly ACCOUNT_ACTION: NotificationType;
80
- readonly PRIZE_WON: NotificationType;
81
- readonly PRIZE_REVEAL_EXPIRED: NotificationType;
82
- };
57
+ /**
58
+ * SCREAMING_SNAKE accessors, DERIVED from `NOTIFICATION_TYPE_VALUES`.
59
+ *
60
+ * This was a fifth hand-written copy of the union — 24 of the 28 values,
61
+ * missing `emi_installment_due_soon`, `emi_installment_overdue`,
62
+ * `payment_review` and `catalogue_images_stale` — and every entry carried an
63
+ * `as NotificationType` cast, which is precisely the assertion that lets a
64
+ * wrong string through. Built from the array now, so it cannot be short.
65
+ */
66
+ readonly TYPE_VALUES: { [K in NotificationType as Uppercase<K>]: K; };
83
67
  readonly PRIORITY_VALUES: {
84
68
  readonly LOW: NotificationPriority;
85
69
  readonly NORMAL: NotificationPriority;
@@ -110,32 +110,16 @@ export const NOTIFICATION_FIELDS = {
110
110
  RELATED_TYPE: "relatedType",
111
111
  CREATED_AT: "createdAt",
112
112
  UPDATED_AT: "updatedAt",
113
- TYPE_VALUES: {
114
- ORDER_PLACED: "order_placed",
115
- ORDER_CONFIRMED: "order_confirmed",
116
- ORDER_SHIPPED: "order_shipped",
117
- ORDER_DELIVERED: "order_delivered",
118
- ORDER_CANCELLED: "order_cancelled",
119
- BID_PLACED: "bid_placed",
120
- BID_OUTBID: "bid_outbid",
121
- BID_WON: "bid_won",
122
- BID_LOST: "bid_lost",
123
- AUCTION_ENDED: "auction_ended",
124
- REVIEW_APPROVED: "review_approved",
125
- REVIEW_REPLIED: "review_replied",
126
- PRODUCT_AVAILABLE: "product_available",
127
- PROMOTION: "promotion",
128
- SYSTEM: "system",
129
- WELCOME: "welcome",
130
- OFFER_RECEIVED: "offer_received",
131
- OFFER_RESPONDED: "offer_responded",
132
- OFFER_EXPIRED: "offer_expired",
133
- OFFER_COUNTER_ACCEPTED: "offer_counter_accepted",
134
- REFUND_INITIATED: "refund_initiated",
135
- ACCOUNT_ACTION: "account_action",
136
- PRIZE_WON: "prize_won",
137
- PRIZE_REVEAL_EXPIRED: "prize_reveal_expired",
138
- },
113
+ /**
114
+ * SCREAMING_SNAKE accessors, DERIVED from `NOTIFICATION_TYPE_VALUES`.
115
+ *
116
+ * This was a fifth hand-written copy of the union — 24 of the 28 values,
117
+ * missing `emi_installment_due_soon`, `emi_installment_overdue`,
118
+ * `payment_review` and `catalogue_images_stale` — and every entry carried an
119
+ * `as NotificationType` cast, which is precisely the assertion that lets a
120
+ * wrong string through. Built from the array now, so it cannot be short.
121
+ */
122
+ TYPE_VALUES: Object.fromEntries(NOTIFICATION_TYPE_VALUES.map((v) => [v.toUpperCase(), v])),
139
123
  PRIORITY_VALUES: {
140
124
  LOW: "low",
141
125
  NORMAL: "normal",
@@ -2,5 +2,5 @@
2
2
  * page-views.repository.ts (server-only, imports firebase-admin) so the
3
3
  * client-side tracker component can import just this without pulling
4
4
  * firebase-admin into the client bundle. */
5
- export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle"];
5
+ export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle", "grouped-listing"];
6
6
  export type PageViewEntityType = (typeof PAGE_VIEW_ENTITY_TYPES)[number];
@@ -18,4 +18,7 @@ export const PAGE_VIEW_ENTITY_TYPES = [
18
18
  "digital-code",
19
19
  "live",
20
20
  "bundle",
21
+ /** A `groupedListings` document's own page. Distinct from "bundle" — a
22
+ * bundle is a priced, all-or-nothing set; a group is pick-as-you-wish. */
23
+ "grouped-listing",
21
24
  ];
@@ -4,7 +4,7 @@
4
4
  * Pure business functions — auth, rate-limiting, and Next.js specifics
5
5
  * are handled by the calling server action in the consumer.
6
6
  */
7
- import type { AddToCartInput, CartDocument, UpdateCartItemInput } from "../schemas";
7
+ import type { AddToCartInput, CartDocument, CartGroupSource, UpdateCartItemInput, UpdateCartGroupMembersInput } from "../schemas";
8
8
  /**
9
9
  * Lane gate. While a higher-obligation lane (a won auction, then an accepted
10
10
  * offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
@@ -24,6 +24,30 @@ export declare function assertCanAddNewItems(userId: string): Promise<CartDocume
24
24
  export declare function addItemToCart(userId: string, input: AddToCartInput): Promise<CartDocument>;
25
25
  /** Error code the client maps to a lane-aware message. */
26
26
  export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
27
+ /** Max members one grouped line may hold. Keeps the batch read bounded (Rule #6). */
28
+ export declare const GROUP_LINE_MAX_MEMBERS = 20;
29
+ export interface AddGroupLineToCartInput {
30
+ /** `product.groupId` for a product group, or the `groupedListings` doc id. */
31
+ groupId: string;
32
+ groupSource: CartGroupSource;
33
+ /** Ids and quantities ONLY — every other field is re-resolved server-side. */
34
+ members: Array<{
35
+ productId: string;
36
+ quantity: number;
37
+ }>;
38
+ }
39
+ /**
40
+ * Add a buyer-assembled selection of group members as ONE cart line.
41
+ *
42
+ * The client sends ids and quantities and nothing else. Accepting a price, a
43
+ * title or an image from the browser would be a ₹1-group exploit; every
44
+ * snapshot below is read from Firestore here.
45
+ *
46
+ * A rejected member fails the WHOLE request, naming the offender. Silently
47
+ * dropping it would hand the buyer a line that quietly isn't what they picked —
48
+ * the "wrong data, no error" failure nobody can report.
49
+ */
50
+ export declare function addGroupLineToCart(userId: string, input: AddGroupLineToCartInput): Promise<CartDocument>;
27
51
  /**
28
52
  * Won-auction lines are non-removable and fixed-quantity — the buyer committed
29
53
  * to the purchase by bidding, and letting them delete it would turn every
@@ -34,6 +58,12 @@ export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
34
58
  * and the offer simply lapses at its `checkoutDeadline`.
35
59
  */
36
60
  export declare function updateCartItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
61
+ /**
62
+ * Replace a grouped line's member quantities. A member set to 0 is dropped;
63
+ * dropping the last one removes the line (enforced in the repository so a
64
+ * forgetful caller can't leave an empty ghost line behind).
65
+ */
66
+ export declare function updateCartGroupMembers(userId: string, itemId: string, input: UpdateCartGroupMembersInput): Promise<CartDocument>;
37
67
  export declare function removeCartItem(userId: string, itemId: string): Promise<CartDocument>;
38
68
  export declare function clearCart(userId: string): Promise<CartDocument>;
39
69
  export declare function mergeGuestCart(userId: string, items: Array<{
@@ -12,6 +12,8 @@ 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
14
  import { CART_LANE, activeLane, canAddNewItems, } from "../../../_internal/shared/checkout/lanes";
15
+ import { CART_MAX_ITEMS } from "../../../constants/limits";
16
+ import { getExpandedDecrements, validateCartItemStock, } from "../../../_internal/server/features/checkout/bundle-expansion";
15
17
  /**
16
18
  * Product documents don't always carry a denormalized `storeName` — the
17
19
  * create routes only set it when the caller explicitly passes one, so
@@ -67,6 +69,159 @@ export async function addItemToCart(userId, input) {
67
69
  }
68
70
  /** Error code the client maps to a lane-aware message. */
69
71
  export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
72
+ /** Max members one grouped line may hold. Keeps the batch read bounded (Rule #6). */
73
+ export const GROUP_LINE_MAX_MEMBERS = 20;
74
+ /**
75
+ * Add a buyer-assembled selection of group members as ONE cart line.
76
+ *
77
+ * The client sends ids and quantities and nothing else. Accepting a price, a
78
+ * title or an image from the browser would be a ₹1-group exploit; every
79
+ * snapshot below is read from Firestore here.
80
+ *
81
+ * A rejected member fails the WHOLE request, naming the offender. Silently
82
+ * dropping it would hand the buyer a line that quietly isn't what they picked —
83
+ * the "wrong data, no error" failure nobody can report.
84
+ */
85
+ export async function addGroupLineToCart(userId, input) {
86
+ const { groupId, groupSource, members } = input;
87
+ if (!groupId)
88
+ throw new ValidationError("A group id is required.");
89
+ if (!members.length)
90
+ throw new ValidationError("Select at least one item to add.");
91
+ if (members.length > GROUP_LINE_MAX_MEMBERS) {
92
+ throw new ValidationError(`A single group line can hold at most ${GROUP_LINE_MAX_MEMBERS} different items.`);
93
+ }
94
+ for (const m of members) {
95
+ if (!Number.isInteger(m.quantity) || m.quantity < 1) {
96
+ throw new ValidationError("Every selected item needs a whole quantity of at least 1.");
97
+ }
98
+ }
99
+ if (new Set(members.map((m) => m.productId)).size !== members.length) {
100
+ throw new ValidationError("The same item was selected twice.");
101
+ }
102
+ const cart = await assertCanAddNewItems(userId);
103
+ // One batched read for the whole selection — capped at 20 above, so well
104
+ // inside the per-request Firestore budget.
105
+ const docs = await Promise.all(members.map((m) => productRepository.findById(m.productId).catch(() => null)));
106
+ const productById = new Map(members.map((m, i) => [m.productId, docs[i]]));
107
+ const { getListingRule } = await import("../../../_internal/shared/checkout/rules");
108
+ const { ProductStatusValues } = await import("../../products/schemas/firestore");
109
+ const resolved = [];
110
+ for (const m of members) {
111
+ const product = productById.get(m.productId);
112
+ if (!product) {
113
+ throw new ValidationError(`One of the selected items no longer exists.`);
114
+ }
115
+ if (product.status !== ProductStatusValues.PUBLISHED || product.isSold) {
116
+ throw new ValidationError(`"${product.title}" is no longer available.`);
117
+ }
118
+ // Membership. Without this a caller could staple arbitrary products into
119
+ // one line and buy them under a group's identity.
120
+ const belongs = groupSource === "product-group"
121
+ ? product.groupId === groupId
122
+ : await isGroupedListingMember(groupId, product.id);
123
+ if (!belongs) {
124
+ throw new ValidationError(`"${product.title}" is not part of this group.`);
125
+ }
126
+ const listingType = normalizeListingType(product);
127
+ const rule = getListingRule(listingType);
128
+ if (!rule.cartEligible) {
129
+ throw new ValidationError(`"${product.title}" can't be added to the cart. ${rule.cartIneligibleHint ?? ""}`.trim());
130
+ }
131
+ if ((product.availableQuantity ?? 0) < m.quantity) {
132
+ throw new ValidationError(`Only ${product.availableQuantity ?? 0} left of "${product.title}".`);
133
+ }
134
+ resolved.push({
135
+ productId: product.id,
136
+ quantity: m.quantity,
137
+ unitPrice: product.price,
138
+ title: product.title,
139
+ image: product.images?.[0] ?? product.mainImage,
140
+ gstRate: product.gstRate,
141
+ hsnCode: product.hsnCode,
142
+ listingType,
143
+ });
144
+ }
145
+ // Single-store rule. `storeId` is the order-splitting key AND the key the
146
+ // per-store add-ons, coupons, shipping and payout all hang off, so a line
147
+ // spanning two stores would break five things at once. The picker hides
148
+ // itself for a cross-store group; this is the server-side half of that.
149
+ const storeIds = new Set(resolved.map((r) => productById.get(r.productId)?.storeId ?? ""));
150
+ if (storeIds.size > 1) {
151
+ throw new ValidationError("These items are sold by different sellers, so they can't be combined into one cart line.");
152
+ }
153
+ const storeId = [...storeIds][0] ?? "";
154
+ const first = productById.get(resolved[0].productId);
155
+ // A one-member selection is just an ordinary line: 1 item × qty N is exactly
156
+ // what a normal cart line already means, and routing it through the group
157
+ // path would create a second representation of the same thing.
158
+ if (resolved.length === 1) {
159
+ return addItemToCart(userId, {
160
+ productId: first.id,
161
+ productTitle: first.title,
162
+ productImage: first.images?.[0] ?? first.mainImage,
163
+ price: first.price,
164
+ currency: first.currency,
165
+ quantity: resolved[0].quantity,
166
+ storeId,
167
+ storeName: first.storeName ?? "",
168
+ listingType: resolved[0].listingType ?? "standard",
169
+ });
170
+ }
171
+ // A grouped line counts as ONE item against the distinct-items cap.
172
+ if (cart.items.length >= CART_MAX_ITEMS) {
173
+ throw new ValidationError(`Cart full (${cart.items.length}/${CART_MAX_ITEMS}). Remove items to add new ones.`);
174
+ }
175
+ // Same stock check checkout will run, so add-time and checkout-time agree by
176
+ // construction rather than by two similar-looking implementations.
177
+ const candidate = {
178
+ itemId: "candidate",
179
+ productId: groupId,
180
+ quantity: 1,
181
+ groupMembers: resolved,
182
+ };
183
+ const expansion = getExpandedDecrements([...cart.items, candidate]);
184
+ const shortfall = validateCartItemStock(candidate, new Map([...productById.entries()].filter(([, p]) => p !== null)), expansion.decrements);
185
+ if (shortfall) {
186
+ const short = productById.get(shortfall.productId);
187
+ throw new ValidationError(`Only ${shortfall.availableQty} left of "${short?.title ?? shortfall.productId}" once your cart is counted.`);
188
+ }
189
+ const groupTitle = await resolveGroupTitle(groupId, groupSource, first ?? null);
190
+ return cartRepository.addGroupLine(userId, {
191
+ productId: groupId,
192
+ productTitle: groupTitle,
193
+ productImage: resolved[0].image ?? "",
194
+ currency: first?.currency ?? getDefaultCurrency(),
195
+ // Pinned to 1: member quantities carry the whole selection, and a copies
196
+ // multiplier on top would double-count. See the invariant on CartLineMember.
197
+ quantity: 1,
198
+ storeId,
199
+ storeName: await resolveStoreName(storeId, first?.storeName),
200
+ listingType: "standard",
201
+ lineKind: "group",
202
+ groupSource,
203
+ groupId,
204
+ groupSlug: groupSource === "product-group" ? groupId : undefined,
205
+ groupTitle,
206
+ groupMembers: resolved,
207
+ });
208
+ }
209
+ /** Is this product listed on that `groupedListings` document? */
210
+ async function isGroupedListingMember(groupId, productId) {
211
+ const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
212
+ const group = await groupedListingsRepository.findById(groupId).catch(() => null);
213
+ return Boolean(group?.productIds?.includes(productId));
214
+ }
215
+ /** Display name for the line. Falls back to the parent product's group title. */
216
+ async function resolveGroupTitle(groupId, groupSource, sample) {
217
+ if (groupSource === "grouped-listing") {
218
+ const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
219
+ const group = await groupedListingsRepository.findById(groupId).catch(() => null);
220
+ if (group?.title)
221
+ return group.title;
222
+ }
223
+ return sample?.groupTitle ?? "Selected items";
224
+ }
70
225
  /**
71
226
  * Won-auction lines are non-removable and fixed-quantity — the buyer committed
72
227
  * to the purchase by bidding, and letting them delete it would turn every
@@ -79,6 +234,14 @@ export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
79
234
  export async function updateCartItem(userId, itemId, input) {
80
235
  return cartRepository.updateItem(userId, itemId, input);
81
236
  }
237
+ /**
238
+ * Replace a grouped line's member quantities. A member set to 0 is dropped;
239
+ * dropping the last one removes the line (enforced in the repository so a
240
+ * forgetful caller can't leave an empty ghost line behind).
241
+ */
242
+ export async function updateCartGroupMembers(userId, itemId, input) {
243
+ return cartRepository.updateGroupMembers(userId, itemId, input);
244
+ }
82
245
  export async function removeCartItem(userId, itemId) {
83
246
  return cartRepository.removeItem(userId, itemId);
84
247
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * CartGroupLineRow — one cart line that stands for several products.
3
+ *
4
+ * Two shapes behind one component, because the difference is exactly the
5
+ * bundle-vs-group distinction and putting them in separate files would let it
6
+ * drift:
7
+ *
8
+ * BUNDLE — all-or-nothing. Members render read-only; the only stepper is the
9
+ * line's own and it means COPIES OF THE BUNDLE.
10
+ * GROUP — pick-as-you-wish. One stepper PER MEMBER, and no line-level
11
+ * stepper at all (a group line's `quantity` is pinned to 1, so a
12
+ * second multiplier would double-count).
13
+ *
14
+ * Never render both steppers. That is the invariant on `CartLineMember`
15
+ * expressed in UI.
16
+ */
17
+ import React from "react";
18
+ import type { CartLineMember } from "../schemas";
19
+ export interface CartGroupLineRowProps {
20
+ /** Line identity (`CartItemDocument.itemId`). */
21
+ id: string;
22
+ title: string;
23
+ image?: string;
24
+ currency?: string;
25
+ /** `"bundle"` → copies stepper; `"group"` → per-member steppers. */
26
+ lineKind: "bundle" | "group";
27
+ members: CartLineMember[];
28
+ /** Copies of the whole line. Bundles only — a group line is always 1. */
29
+ quantity: number;
30
+ /** Detail href for the bundle / group itself. */
31
+ href?: string;
32
+ locked?: boolean;
33
+ /** Bundle only: change the number of copies. */
34
+ onQtyChange?: (id: string, qty: number) => void;
35
+ /** Group only: change one member's per-copy quantity (0 removes it). */
36
+ onMemberQtyChange?: (id: string, productId: string, qty: number) => void;
37
+ onRemove?: (id: string) => void;
38
+ /** Per-member stock ceiling, keyed by productId. */
39
+ maxByProductId?: Record<string, number>;
40
+ variant?: "card" | "row";
41
+ }
42
+ export declare function CartGroupLineRow({ id, title, image, currency, lineKind, members, quantity, href, locked, onQtyChange, onMemberQtyChange, onRemove, maxByProductId, variant, }: CartGroupLineRowProps): React.JSX.Element;
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * CartGroupLineRow — one cart line that stands for several products.
5
+ *
6
+ * Two shapes behind one component, because the difference is exactly the
7
+ * bundle-vs-group distinction and putting them in separate files would let it
8
+ * drift:
9
+ *
10
+ * BUNDLE — all-or-nothing. Members render read-only; the only stepper is the
11
+ * line's own and it means COPIES OF THE BUNDLE.
12
+ * GROUP — pick-as-you-wish. One stepper PER MEMBER, and no line-level
13
+ * stepper at all (a group line's `quantity` is pinned to 1, so a
14
+ * second multiplier would double-count).
15
+ *
16
+ * Never render both steppers. That is the invariant on `CartLineMember`
17
+ * expressed in UI.
18
+ */
19
+ import { useState } from "react";
20
+ import { Button, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
21
+ import { QuantityStepper } from "../../../ui/components/QuantityStepper";
22
+ import { MediaImage } from "../../media/MediaImage";
23
+ import { formatCurrency } from "../../../utils/number.formatter";
24
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
25
+ const __O = { hidden: "overflow-hidden" };
26
+ export function CartGroupLineRow({ id, title, image, currency = "INR", lineKind, members, quantity, href, locked = false, onQtyChange, onMemberQtyChange, onRemove, maxByProductId, variant = "card", }) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const isRow = variant === "row";
29
+ const onePrice = members.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0);
30
+ const lineTotal = onePrice * quantity;
31
+ const unitCount = members.reduce((sum, m) => sum + m.quantity, 0);
32
+ return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: "min-w-0", children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: image && _jsx(MediaImage, { src: image, alt: title, size: "thumbnail" }) }), _jsxs(Stack, { justify: "between", gap: "xs", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", align: "start", className: "min-w-0", children: [href ? (_jsx(TextLink, { href: href, weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: title })), _jsx(Span, { padding: "pill-2xs", surface: "subtle", color: "muted", rounded: "default", className: "flex-shrink-0 text-[10px] tracking-wide", transform: "uppercase", children: lineKind === "bundle" ? "Bundle" : "Group" })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsOpen((v) => !v), "aria-expanded": isOpen, className: "self-start text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: [isOpen ? "▼" : "▶", " ", members.length, " item", members.length === 1 ? "" : "s", unitCount !== members.length ? ` · ${unitCount} units` : ""] }), isOpen && (_jsx(Stack, { gap: "xs", className: "w-full", children: members.map((m) => (_jsxs(Row, { gap: "sm", align: "center", justify: "between", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", className: "min-w-0 flex-1", children: [_jsx(Div, { className: `h-10 w-10 flex-shrink-0 ${__O.hidden}`, rounded: "default", surface: "muted", children: m.image && _jsx(MediaImage, { src: m.image, alt: m.title, size: "thumbnail" }) }), _jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsx(Text, { size: "sm", truncate: 1, color: "primary", children: m.title }), _jsxs(Text, { size: "xs", color: "muted", children: [formatCurrency(m.unitPrice, currency), lineKind === "bundle" ? ` × ${m.quantity}` : ""] })] })] }), lineKind === "group" && onMemberQtyChange && !locked && (_jsxs(Row, { gap: "xs", align: "center", className: "flex-shrink-0", children: [_jsx(QuantityStepper, { value: m.quantity, onChange: (next) => onMemberQtyChange(id, m.productId, next), min: 0, max: maxByProductId?.[m.productId], ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-group-member"].ariaLabel, onClick: () => onMemberQtyChange(id, m.productId, 0), className: "text-[var(--appkit-color-text-faint)] transition hover:text-error", children: "\u2715" })] })), lineKind === "bundle" && (_jsxs(Span, { size: "xs", color: "muted", className: "flex-shrink-0", children: ["\u00D7 ", m.quantity] }))] }, m.productId))) })), _jsxs(Row, { justify: "between", gap: "sm", className: "min-w-0", children: [_jsx(Text, { weight: "semibold", className: "text-[var(--appkit-color-text)]", children: formatCurrency(lineTotal, currency) }), lineKind === "bundle" && onQtyChange && !locked && (_jsx(QuantityStepper, { value: quantity, onChange: (next) => onQtyChange(id, next), min: 1, ariaLabel: `Number of copies of ${title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel }))] })] }), onRemove && !locked && (_jsx(Button, { onClick: () => onRemove(id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: "self-start text-[var(--appkit-color-text-faint)] transition hover:text-error", children: "\u2715" }))] }));
33
+ }
@@ -1,4 +1,6 @@
1
1
  export { CartItemRow, CartDrawer } from "./CartDrawer";
2
+ export { CartGroupLineRow } from "./CartGroupLineRow";
3
+ export type { CartGroupLineRowProps } from "./CartGroupLineRow";
2
4
  export { CartView } from "./CartView";
3
5
  export type { CartViewProps, CartOrderGroup } from "./CartView";
4
6
  export { CartSummary } from "./CartSummary";
@@ -1,4 +1,5 @@
1
1
  export { CartItemRow, CartDrawer } from "./CartDrawer";
2
+ export { CartGroupLineRow } from "./CartGroupLineRow";
2
3
  export { CartView } from "./CartView";
3
4
  export { CartSummary } from "./CartSummary";
4
5
  export { StoreAddonsPicker } from "./StoreAddonsPicker";
@@ -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 CartStoreAddons, type CartItemDocument, type UpdateCartItemInput } from "../schemas";
4
+ import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type AddGroupLineInput, type UpdateCartItemInput, type UpdateCartGroupMembersInput } from "../schemas";
5
5
  export declare class CartRepository extends BaseRepository<CartDocument> {
6
6
  private static readonly SIEVE_FIELDS;
7
7
  constructor();
@@ -12,6 +12,38 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
12
12
  getOrCreate(userId: string): Promise<CartDocument>;
13
13
  createEmpty(userId: string): Promise<CartDocument>;
14
14
  addItem(userId: string, input: AddToCartInput): Promise<CartDocument>;
15
+ /**
16
+ * Price of ONE COPY of a multi-member line.
17
+ *
18
+ * Private and called at every write of `groupMembers`, so `item.price` cannot
19
+ * drift from the members it is supposed to summarise — no caller sets it, so
20
+ * no caller can forget it. That property is load-bearing well beyond this
21
+ * file: `getSubtotal`, the cart page's per-seller subtotal, and both lane
22
+ * subtotals all compute `price × quantity`, and a grouped line pins
23
+ * `quantity` to 1, so all four stay correct with no changes of their own.
24
+ */
25
+ private deriveLinePrice;
26
+ /**
27
+ * Create a multi-member line. Deliberately never merges into an existing
28
+ * line — see the note in `addItem`.
29
+ */
30
+ addGroupLine(userId: string, input: AddGroupLineInput): Promise<CartDocument>;
31
+ /**
32
+ * Replace a grouped line's member quantities.
33
+ *
34
+ * Takes the WHOLE array rather than a per-member delta: the cart is one
35
+ * Firestore document written with `set()`, so N per-member writes would be
36
+ * both a Rule #6 cost and a genuine lost-update race on a fast double-click.
37
+ *
38
+ * A member dropped to 0 is removed, and **a line left with no members is
39
+ * removed entirely** — enforced here rather than in the client, so a caller
40
+ * that forgets cannot leave an empty ghost line in the document.
41
+ *
42
+ * Only quantities move. Prices, titles and images are the snapshots taken
43
+ * server-side at add time and are never re-accepted from a caller; adding a
44
+ * NEW member is an add-to-cart, which has its own eligibility checks.
45
+ */
46
+ updateGroupMembers(userId: string, itemId: string, input: UpdateCartGroupMembersInput): Promise<CartDocument>;
15
47
  updateItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
16
48
  removeItem(userId: string, itemId: string): Promise<CartDocument>;
17
49
  clearCart(userId: string): Promise<CartDocument>;