@mohasinac/appkit 4.24.0 → 4.25.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/checkout/actions.js +6 -5
- package/dist/_internal/server/features/checkout/bundle-expansion.d.ts +4 -6
- package/dist/_internal/server/features/checkout/bundle-expansion.js +19 -17
- package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
- package/dist/_internal/server/features/products/group-members.d.ts +55 -0
- package/dist/_internal/server/features/products/group-members.js +41 -0
- package/dist/_internal/shared/checkout/line-members.d.ts +60 -0
- package/dist/_internal/shared/checkout/line-members.js +68 -0
- package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +27 -0
- package/dist/constants/api-endpoints.js +9 -0
- package/dist/features/admin/schemas/firestore.d.ts +10 -26
- package/dist/features/admin/schemas/firestore.js +10 -26
- package/dist/features/cart/actions/cart-actions.d.ts +31 -1
- package/dist/features/cart/actions/cart-actions.js +163 -0
- package/dist/features/cart/repository/cart.repository.d.ts +33 -1
- package/dist/features/cart/repository/cart.repository.js +146 -2
- package/dist/features/products/components/GroupMemberPicker.d.ts +46 -0
- package/dist/features/products/components/GroupMemberPicker.js +108 -0
- package/dist/features/products/components/ShowGroupSection.js +7 -9
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/seed/notifications-seed-data.js +225 -0
- package/dist/seed/payouts-seed-data.js +47 -0
- package/dist/seed/scammers-seed-data.js +34 -0
- package/dist/seed/stores-seed-data.js +34 -0
- package/dist/seed/support-tickets-seed-data.js +34 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -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>;
|
|
@@ -72,10 +72,17 @@ export class CartRepository extends BaseRepository {
|
|
|
72
72
|
if (input.bidId && items.some((item) => item.bidId === input.bidId)) {
|
|
73
73
|
return cart;
|
|
74
74
|
}
|
|
75
|
+
// A grouped line is never merged by productId. Two "Add selected"
|
|
76
|
+
// presses are two different selections, and merging them would silently
|
|
77
|
+
// rewrite the buyer's per-member quantities into a copy count. Bundles
|
|
78
|
+
// DO still merge — the same bundle twice genuinely means two copies.
|
|
75
79
|
const isLockedLine = Boolean(input.offerId || input.bidId);
|
|
76
|
-
const existingIndex = isLockedLine
|
|
80
|
+
const existingIndex = isLockedLine || input.lineKind === "group"
|
|
77
81
|
? -1
|
|
78
|
-
: items.findIndex((item) => item.productId === input.productId &&
|
|
82
|
+
: items.findIndex((item) => item.productId === input.productId &&
|
|
83
|
+
!item.offerId &&
|
|
84
|
+
!item.bidId &&
|
|
85
|
+
item.lineKind !== "group");
|
|
79
86
|
if (existingIndex >= 0) {
|
|
80
87
|
items[existingIndex] = {
|
|
81
88
|
...items[existingIndex],
|
|
@@ -115,6 +122,12 @@ export class CartRepository extends BaseRepository {
|
|
|
115
122
|
...(input.bundleProductIds !== undefined && {
|
|
116
123
|
bundleProductIds: input.bundleProductIds,
|
|
117
124
|
}),
|
|
125
|
+
...(input.lineKind !== undefined && { lineKind: input.lineKind }),
|
|
126
|
+
...(input.groupSource !== undefined && { groupSource: input.groupSource }),
|
|
127
|
+
...(input.groupId !== undefined && { groupId: input.groupId }),
|
|
128
|
+
...(input.groupSlug !== undefined && { groupSlug: input.groupSlug }),
|
|
129
|
+
...(input.groupTitle !== undefined && { groupTitle: input.groupTitle }),
|
|
130
|
+
...(input.groupMembers !== undefined && { groupMembers: input.groupMembers }),
|
|
118
131
|
addedAt: new Date(),
|
|
119
132
|
updatedAt: new Date(),
|
|
120
133
|
};
|
|
@@ -137,6 +150,137 @@ export class CartRepository extends BaseRepository {
|
|
|
137
150
|
throw new DatabaseError("Failed to add item to cart", error);
|
|
138
151
|
}
|
|
139
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Price of ONE COPY of a multi-member line.
|
|
155
|
+
*
|
|
156
|
+
* Private and called at every write of `groupMembers`, so `item.price` cannot
|
|
157
|
+
* drift from the members it is supposed to summarise — no caller sets it, so
|
|
158
|
+
* no caller can forget it. That property is load-bearing well beyond this
|
|
159
|
+
* file: `getSubtotal`, the cart page's per-seller subtotal, and both lane
|
|
160
|
+
* subtotals all compute `price × quantity`, and a grouped line pins
|
|
161
|
+
* `quantity` to 1, so all four stay correct with no changes of their own.
|
|
162
|
+
*/
|
|
163
|
+
deriveLinePrice(members) {
|
|
164
|
+
const raw = members.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0);
|
|
165
|
+
// audit-money-units-ok: decimal rupees throughout; rounding to 2dp, not paise conversion.
|
|
166
|
+
return Math.round(raw * 100) / 100;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Create a multi-member line. Deliberately never merges into an existing
|
|
170
|
+
* line — see the note in `addItem`.
|
|
171
|
+
*/
|
|
172
|
+
async addGroupLine(userId, input) {
|
|
173
|
+
try {
|
|
174
|
+
if (!input.groupMembers.length) {
|
|
175
|
+
throw new ValidationError("A grouped cart line needs at least one member.");
|
|
176
|
+
}
|
|
177
|
+
const cart = await this.getOrCreate(userId);
|
|
178
|
+
const newItem = {
|
|
179
|
+
itemId: randomUUID(),
|
|
180
|
+
productId: input.productId,
|
|
181
|
+
productTitle: input.productTitle,
|
|
182
|
+
productImage: input.productImage,
|
|
183
|
+
price: this.deriveLinePrice(input.groupMembers),
|
|
184
|
+
currency: input.currency,
|
|
185
|
+
quantity: input.quantity,
|
|
186
|
+
storeId: input.storeId,
|
|
187
|
+
storeName: input.storeName,
|
|
188
|
+
listingType: input.listingType,
|
|
189
|
+
groupMembers: input.groupMembers,
|
|
190
|
+
...(input.lineKind !== undefined && { lineKind: input.lineKind }),
|
|
191
|
+
...(input.groupSource !== undefined && { groupSource: input.groupSource }),
|
|
192
|
+
...(input.groupId !== undefined && { groupId: input.groupId }),
|
|
193
|
+
...(input.groupSlug !== undefined && { groupSlug: input.groupSlug }),
|
|
194
|
+
...(input.groupTitle !== undefined && { groupTitle: input.groupTitle }),
|
|
195
|
+
...(input.bundleCategorySlug !== undefined && {
|
|
196
|
+
bundleCategorySlug: input.bundleCategorySlug,
|
|
197
|
+
}),
|
|
198
|
+
addedAt: new Date(),
|
|
199
|
+
updatedAt: new Date(),
|
|
200
|
+
};
|
|
201
|
+
const updatedCart = {
|
|
202
|
+
...cart,
|
|
203
|
+
items: [...cart.items, newItem],
|
|
204
|
+
updatedAt: new Date(),
|
|
205
|
+
};
|
|
206
|
+
await this.db
|
|
207
|
+
.collection(this.collection)
|
|
208
|
+
.doc(userId)
|
|
209
|
+
.set(prepareForFirestore(updatedCart));
|
|
210
|
+
return updatedCart;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error instanceof DatabaseError || error instanceof ValidationError)
|
|
214
|
+
throw error;
|
|
215
|
+
throw new DatabaseError("Failed to add grouped line to cart", error);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Replace a grouped line's member quantities.
|
|
220
|
+
*
|
|
221
|
+
* Takes the WHOLE array rather than a per-member delta: the cart is one
|
|
222
|
+
* Firestore document written with `set()`, so N per-member writes would be
|
|
223
|
+
* both a Rule #6 cost and a genuine lost-update race on a fast double-click.
|
|
224
|
+
*
|
|
225
|
+
* A member dropped to 0 is removed, and **a line left with no members is
|
|
226
|
+
* removed entirely** — enforced here rather than in the client, so a caller
|
|
227
|
+
* that forgets cannot leave an empty ghost line in the document.
|
|
228
|
+
*
|
|
229
|
+
* Only quantities move. Prices, titles and images are the snapshots taken
|
|
230
|
+
* server-side at add time and are never re-accepted from a caller; adding a
|
|
231
|
+
* NEW member is an add-to-cart, which has its own eligibility checks.
|
|
232
|
+
*/
|
|
233
|
+
async updateGroupMembers(userId, itemId, input) {
|
|
234
|
+
try {
|
|
235
|
+
const cart = await this.findByUserId(userId);
|
|
236
|
+
if (!cart)
|
|
237
|
+
throw new NotFoundError("Cart not found");
|
|
238
|
+
const itemIndex = cart.items.findIndex((item) => item.itemId === itemId);
|
|
239
|
+
if (itemIndex < 0)
|
|
240
|
+
throw new NotFoundError(ERR_CART_ITEM_NOT_FOUND);
|
|
241
|
+
const target = cart.items[itemIndex];
|
|
242
|
+
if (target.locked)
|
|
243
|
+
throw new ValidationError(ERR_CART_ITEM_LOCKED);
|
|
244
|
+
if (!target.groupMembers?.length) {
|
|
245
|
+
throw new ValidationError("This cart line has no members to update.");
|
|
246
|
+
}
|
|
247
|
+
const requested = new Map(input.groupMembers.map((m) => [m.productId, m.quantity]));
|
|
248
|
+
for (const productId of requested.keys()) {
|
|
249
|
+
if (!target.groupMembers.some((m) => m.productId === productId)) {
|
|
250
|
+
throw new ValidationError("That item is not part of this cart line. Add it from the group page instead.");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const nextMembers = target.groupMembers
|
|
254
|
+
.map((m) => (requested.has(m.productId)
|
|
255
|
+
? { ...m, quantity: requested.get(m.productId) }
|
|
256
|
+
: m))
|
|
257
|
+
.filter((m) => m.quantity > 0);
|
|
258
|
+
if (nextMembers.length === 0) {
|
|
259
|
+
return this.removeItem(userId, itemId);
|
|
260
|
+
}
|
|
261
|
+
const items = [...cart.items];
|
|
262
|
+
items[itemIndex] = {
|
|
263
|
+
...target,
|
|
264
|
+
groupMembers: nextMembers,
|
|
265
|
+
price: this.deriveLinePrice(nextMembers),
|
|
266
|
+
updatedAt: new Date(),
|
|
267
|
+
};
|
|
268
|
+
const updatedCart = { ...cart, items, updatedAt: new Date() };
|
|
269
|
+
await this.db
|
|
270
|
+
.collection(this.collection)
|
|
271
|
+
.doc(userId)
|
|
272
|
+
.set(prepareForFirestore(updatedCart));
|
|
273
|
+
return updatedCart;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error instanceof DatabaseError ||
|
|
277
|
+
error instanceof NotFoundError ||
|
|
278
|
+
error instanceof ValidationError) {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
throw new DatabaseError("Failed to update grouped cart line", error);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
140
284
|
async updateItem(userId, itemId, input) {
|
|
141
285
|
try {
|
|
142
286
|
const cart = await this.findByUserId(userId);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GroupMemberPicker — pick which members of a group you want, and how many of
|
|
3
|
+
* each, then add the whole selection as ONE cart line.
|
|
4
|
+
*
|
|
5
|
+
* This is the "pick as you wish" half of the grouped-purchase model. Bundles
|
|
6
|
+
* are the other half and deliberately do NOT use this: a bundle is priced as a
|
|
7
|
+
* whole and is all-or-nothing, so its members render read-only and the only
|
|
8
|
+
* stepper is the bundle's own copy count.
|
|
9
|
+
*
|
|
10
|
+
* Two mount points, one component: the "Part of: …" modal/drawer on a product
|
|
11
|
+
* detail page, and the public `/groups/{slug}` page.
|
|
12
|
+
*
|
|
13
|
+
* The component talks to the API directly rather than taking an `onAdd` server
|
|
14
|
+
* action prop. That keeps `ShowGroupSection`'s `renderGroupSection` slot
|
|
15
|
+
* signature unchanged, which in turn leaves both detail-page views and both
|
|
16
|
+
* consumer pages untouched.
|
|
17
|
+
*/
|
|
18
|
+
import React from "react";
|
|
19
|
+
import type { ListingType } from "../types";
|
|
20
|
+
export interface GroupPickerMember {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
price: number;
|
|
24
|
+
currency?: string;
|
|
25
|
+
images?: string[];
|
|
26
|
+
image?: string;
|
|
27
|
+
slug?: string;
|
|
28
|
+
listingType?: ListingType;
|
|
29
|
+
isGroupParent?: boolean;
|
|
30
|
+
condition?: string;
|
|
31
|
+
availableQuantity?: number;
|
|
32
|
+
storeId?: string;
|
|
33
|
+
storeName?: string;
|
|
34
|
+
isSold?: boolean;
|
|
35
|
+
status?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface GroupMemberPickerProps {
|
|
38
|
+
groupId: string;
|
|
39
|
+
groupSource: "product-group" | "grouped-listing";
|
|
40
|
+
members: GroupPickerMember[];
|
|
41
|
+
/** Called after a successful add so the host can close its overlay. */
|
|
42
|
+
onAdded?: () => void;
|
|
43
|
+
/** Called when the API reports the caller isn't signed in. */
|
|
44
|
+
onAuthRequired?: () => void;
|
|
45
|
+
}
|
|
46
|
+
export declare function GroupMemberPicker({ groupId, groupSource, members, onAdded, onAuthRequired, }: GroupMemberPickerProps): React.JSX.Element;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* GroupMemberPicker — pick which members of a group you want, and how many of
|
|
5
|
+
* each, then add the whole selection as ONE cart line.
|
|
6
|
+
*
|
|
7
|
+
* This is the "pick as you wish" half of the grouped-purchase model. Bundles
|
|
8
|
+
* are the other half and deliberately do NOT use this: a bundle is priced as a
|
|
9
|
+
* whole and is all-or-nothing, so its members render read-only and the only
|
|
10
|
+
* stepper is the bundle's own copy count.
|
|
11
|
+
*
|
|
12
|
+
* Two mount points, one component: the "Part of: …" modal/drawer on a product
|
|
13
|
+
* detail page, and the public `/groups/{slug}` page.
|
|
14
|
+
*
|
|
15
|
+
* The component talks to the API directly rather than taking an `onAdd` server
|
|
16
|
+
* action prop. That keeps `ShowGroupSection`'s `renderGroupSection` slot
|
|
17
|
+
* signature unchanged, which in turn leaves both detail-page views and both
|
|
18
|
+
* consumer pages untouched.
|
|
19
|
+
*/
|
|
20
|
+
import { useMemo, useState } from "react";
|
|
21
|
+
import { Alert, Button, Div, Row, Span, Stack, Table, Tbody, Td, Text, Th, Thead, Tr, } from "../../../ui";
|
|
22
|
+
import { QuantityStepper } from "../../../ui/components/QuantityStepper";
|
|
23
|
+
import { MediaImage } from "../../media/MediaImage";
|
|
24
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
25
|
+
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
26
|
+
import { CART_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
27
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
28
|
+
import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
|
|
29
|
+
import { normalizeListingType } from "../utils/listing-type";
|
|
30
|
+
const __O = { xAuto: "overflow-x-auto", hidden: "overflow-hidden" };
|
|
31
|
+
function memberImage(m) {
|
|
32
|
+
return m.image ?? m.images?.[0] ?? "";
|
|
33
|
+
}
|
|
34
|
+
function memberHref(m) {
|
|
35
|
+
return pluginFor(normalizeListingType(m)).detailRoute(m.slug ?? m.id);
|
|
36
|
+
}
|
|
37
|
+
/** A member the buyer cannot choose, and the reason to show instead of a stepper. */
|
|
38
|
+
function blockedReason(m) {
|
|
39
|
+
if (m.isSold)
|
|
40
|
+
return "Sold";
|
|
41
|
+
if (m.status && m.status !== "published")
|
|
42
|
+
return "Unavailable";
|
|
43
|
+
if ((m.availableQuantity ?? 0) <= 0)
|
|
44
|
+
return "Out of stock";
|
|
45
|
+
// `cartLine: "blocked"` is how a classified / live listing says "contact the
|
|
46
|
+
// seller instead" — those can never join a cart line.
|
|
47
|
+
if (pluginFor(normalizeListingType(m)).cartLine === "blocked")
|
|
48
|
+
return "Not purchasable here";
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
export function GroupMemberPicker({ groupId, groupSource, members, onAdded, onAuthRequired, }) {
|
|
52
|
+
const [qty, setQty] = useState({});
|
|
53
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
54
|
+
const [error, setError] = useState(null);
|
|
55
|
+
// A group whose members span sellers cannot become one cart line: storeId is
|
|
56
|
+
// the order-splitting key and the key per-store add-ons, coupons, shipping and
|
|
57
|
+
// payout all hang off. Rather than offer a button that always fails, the
|
|
58
|
+
// picker stands down and the table stays read-only.
|
|
59
|
+
const storeIds = useMemo(() => new Set(members.map((m) => m.storeId).filter(Boolean)), [members]);
|
|
60
|
+
const isCrossStore = storeIds.size > 1;
|
|
61
|
+
const selected = useMemo(() => members
|
|
62
|
+
.map((m) => ({ member: m, quantity: qty[m.id] ?? 0 }))
|
|
63
|
+
.filter((s) => s.quantity > 0), [members, qty]);
|
|
64
|
+
const selectedTotal = selected.reduce((sum, s) => sum + s.member.price * s.quantity, 0);
|
|
65
|
+
const selectedUnits = selected.reduce((sum, s) => sum + s.quantity, 0);
|
|
66
|
+
const currency = members[0]?.currency ?? "INR";
|
|
67
|
+
const handleAdd = async () => {
|
|
68
|
+
setError(null);
|
|
69
|
+
setIsSaving(true);
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(CART_ENDPOINTS.GROUP, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
groupId,
|
|
76
|
+
groupSource,
|
|
77
|
+
// Ids and quantities only. Prices and titles are re-resolved on the
|
|
78
|
+
// server — anything else would be a price-manipulation hole.
|
|
79
|
+
members: selected.map((s) => ({ productId: s.member.id, quantity: s.quantity })),
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
if (res.status === 401) {
|
|
83
|
+
onAuthRequired?.();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const json = (await res.json());
|
|
87
|
+
if (!res.ok || json.success === false) {
|
|
88
|
+
setError(json.error ?? "Couldn't add those items to your cart.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setQty({});
|
|
92
|
+
onAdded?.();
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
void normalizeError(err);
|
|
96
|
+
setError("Couldn't reach the server. Please try again.");
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
setIsSaving(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return (_jsxs(Stack, { gap: "sm", children: [isCrossStore && (_jsx(Alert, { variant: "info", children: "These items are sold by different sellers, so they can't be combined into one cart line. Open an item to buy it on its own." })), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { className: "text-left min-w-[440px]", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Image" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Name" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Price" }), !isCrossStore && (_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Qty" })), _jsx(Th, { paddingY: "sm", paddingSide: "pb-sm", color: "muted", size: "xs", weight: "semibold" })] }) }), _jsx(Tbody, { children: members.map((m) => {
|
|
103
|
+
const blocked = blockedReason(m);
|
|
104
|
+
return (_jsxs(Tr, { className: "last:border-0", border: "subtle", children: [_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Div, { className: `w-10 h-10 ${__O.hidden}`, rounded: "full", border: "default", children: _jsx(MediaImage, { src: memberImage(m), alt: m.title, size: "thumbnail" }) }) }), _jsxs(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: [_jsx(Text, { className: "line-clamp-2", color: "primary", size: "sm", weight: "medium", children: m.title }), m.isGroupParent && (_jsx(Span, { weight: "semibold", className: "text-[10px] text-[var(--appkit-color-primary)]", children: "Parent" }))] }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "sm", color: "muted", children: formatCurrency(m.price, m.currency ?? currency) }) }), !isCrossStore && (_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: blocked ? (_jsx(Span, { size: "xs", color: "muted", surface: "muted", rounded: "default", padding: "pill-2xs", children: blocked })) : (_jsx(QuantityStepper, { value: qty[m.id] ?? 0, onChange: (next) => setQty((prev) => ({ ...prev, [m.id]: next })), min: 0, max: m.availableQuantity, ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })) })), _jsx(Td, { padding: "xs-tall", children: _jsx("a", { href: memberHref(m), className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: "View \u2192" }) })] }, m.id));
|
|
105
|
+
}) })] }) }), !isCrossStore && (_jsxs(_Fragment, { children: [error && _jsx(Alert, { variant: "error", children: error }), _jsxs(Row, { justify: "between", align: "center", gap: "sm", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: "muted", children: selectedUnits > 0
|
|
106
|
+
? `${selectedUnits} item${selectedUnits === 1 ? "" : "s"} · ${formatCurrency(selectedTotal, currency)}`
|
|
107
|
+
: "Nothing selected yet" }), _jsx(Button, { action: ACTIONS.CART["add-group-to-cart"], disabled: selectedUnits === 0, isLoading: isSaving, onClick: handleAdd })] })] }))] }));
|
|
108
|
+
}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
-
import { Div, Row, Span,
|
|
5
|
+
import { Div, Row, Span, Text, Modal, SideDrawer, Button } from "../../../ui";
|
|
6
6
|
import { MediaImage } from "../../media/MediaImage";
|
|
7
|
+
import { GroupMemberPicker } from "./GroupMemberPicker";
|
|
7
8
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
8
9
|
import { normalizeListingType } from "../utils/listing-type";
|
|
9
10
|
import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
|
|
@@ -24,12 +25,6 @@ function MemberThumb({ member, isCurrent }) {
|
|
|
24
25
|
? "border-[var(--appkit-color-primary)] ring-2 ring-[var(--appkit-color-primary)]/30"
|
|
25
26
|
: "border-[var(--appkit-color-border)] group-hover:border-[var(--appkit-color-primary)]"}`, children: [_jsx(MediaImage, { src: image, alt: member.title, size: "card" }), member.isGroupParent && (_jsx(Span, { color: "inverse", rounded: "sm", className: "absolute bottom-0 right-0 bg-[var(--appkit-color-primary)] text-[8px] leading-none px-[0.25rem] py-[0.125rem]", children: "Set" }))] }), _jsx(Text, { className: "text-[10px] leading-tight line-clamp-2 w-full", color: "muted", align: "start", children: member.title }), _jsx(Text, { className: "text-[10px]", color: "primary", weight: "semibold", children: price })] }));
|
|
26
27
|
}
|
|
27
|
-
function GroupTableRow({ member }) {
|
|
28
|
-
const href = memberHref(member);
|
|
29
|
-
const price = formatCurrency(member.price, member.currency ?? "INR");
|
|
30
|
-
const image = member.images?.[0] ?? "";
|
|
31
|
-
return (_jsxs(Tr, { className: "last:border-0", border: "subtle", children: [_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Div, { className: `w-10 h-10 ${__O.hidden}`, rounded: "full", border: "default", children: _jsx(MediaImage, { src: image, alt: member.title, size: "thumbnail" }) }) }), _jsxs(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: [_jsx(Text, { className: "line-clamp-2", color: "primary", size: "sm", weight: "medium", children: member.title }), member.isGroupParent && (_jsx(Span, { weight: "semibold", className: "text-[10px] text-[var(--appkit-color-primary)]", children: "Parent" }))] }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "sm", color: "muted", children: price }) }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "xs", transform: "capitalize", color: "muted", children: member.condition ?? "—" }) }), _jsx(Td, { padding: "xs-tall", children: _jsx(Link, { href: href, className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: "View \u2192" }) })] }));
|
|
32
|
-
}
|
|
33
28
|
export function ShowGroupSection({ groupId, currentSlug, isParent, groupTitle }) {
|
|
34
29
|
const [open, setOpen] = useState(false);
|
|
35
30
|
const [members, setMembers] = useState([]);
|
|
@@ -50,11 +45,14 @@ export function ShowGroupSection({ groupId, currentSlug, isParent, groupTitle })
|
|
|
50
45
|
const label = groupTitle ?? "Product group";
|
|
51
46
|
const parentLabel = isParent ? `Parts in this group: ${label}` : `Part of: ${label}`;
|
|
52
47
|
const useDrawer = members.length >= 5;
|
|
53
|
-
|
|
48
|
+
// The overlay is now a PICKER, not a read-only table: pick how many of each
|
|
49
|
+
// member you want and add the whole selection as one cart line. The picker
|
|
50
|
+
// stands itself down (read-only, no CTA) when the group spans sellers.
|
|
51
|
+
const tableContent = (_jsx(GroupMemberPicker, { groupId: groupId, groupSource: "product-group", members: members, onAdded: () => setShowAll(false), onAuthRequired: () => setShowAll(false) }));
|
|
54
52
|
return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `${__O.hidden}`, surface: "muted", rounded: "xl", border: "default", children: [_jsxs(Row, { role: "button", tabIndex: 0, onClick: () => setOpen((v) => !v), onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") {
|
|
55
53
|
e.preventDefault();
|
|
56
54
|
setOpen((v) => !v);
|
|
57
|
-
} }, justify: "between", paddingX: "x-md", paddingY: "y-sm", className: "w-full cursor-pointer text-left hover:bg-[var(--appkit-color-surface-elevated)]/70 transition-colors", "aria-expanded": open, children: [_jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Span, { size: "xs", className: "mr-1", color: "faint", children: open ? "▼" : "▶" }), _jsx(Text, { size: "sm", weight: "medium", color: "primary", children: parentLabel }), _jsx(Span, { size: "xs", className: "ml-1", rounded: "full", padding: "pill-xs", surface: "subtle", color: "muted", children: members.length })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); setShowAll(true); }, className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline ml-3 flex-shrink-0", children: "
|
|
55
|
+
} }, justify: "between", paddingX: "x-md", paddingY: "y-sm", className: "w-full cursor-pointer text-left hover:bg-[var(--appkit-color-surface-elevated)]/70 transition-colors", "aria-expanded": open, children: [_jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Span, { size: "xs", className: "mr-1", color: "faint", children: open ? "▼" : "▶" }), _jsx(Text, { size: "sm", weight: "medium", color: "primary", children: parentLabel }), _jsx(Span, { size: "xs", className: "ml-1", rounded: "full", padding: "pill-xs", surface: "subtle", color: "muted", children: members.length })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); setShowAll(true); }, className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline ml-3 flex-shrink-0", children: "Pick items \u2192" })] }), open && (_jsx(Div, { className: `pb-[1rem] pt-[0.25rem] ${__O.xAuto}`, padding: "x-md", children: _jsx(Row, { gap: "3", className: "min-w-0", children: [...members]
|
|
58
56
|
.sort((a, b) => (b.isGroupParent ? 1 : 0) - (a.isGroupParent ? 1 : 0))
|
|
59
57
|
.map((m) => (_jsx(MemberThumb, { member: m, isCurrent: m.slug === currentSlug || m.id === currentSlug }, m.id))) }) }))] }), useDrawer ? (_jsx(SideDrawer, { isOpen: showAll, onClose: () => setShowAll(false), title: label, children: tableContent })) : (_jsx(Modal, { open: showAll, onClose: () => setShowAll(false), title: label, children: tableContent }))] }));
|
|
60
58
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1551,7 +1551,8 @@ export { updateBlogPostSchema } from "./features/blog/server";
|
|
|
1551
1551
|
export type { BlogPostDetailResponse } from "./features/blog/server";
|
|
1552
1552
|
export type { CreateBlogPostInput } from "./features/blog/server";
|
|
1553
1553
|
export type { UpdateBlogPostInput } from "./features/blog/server";
|
|
1554
|
-
export { addItemToCart, assertCanAddNewItems } from "./features/cart/server";
|
|
1554
|
+
export { addItemToCart, assertCanAddNewItems, addGroupLineToCart, updateCartGroupMembers, GROUP_LINE_MAX_MEMBERS, type AddGroupLineToCartInput, } from "./features/cart/server";
|
|
1555
|
+
export { toPublicGroupMember, toPublicGroupMembers, type PublicGroupMember, } from "./_internal/server/features/products/group-members";
|
|
1555
1556
|
export { clearCart } from "./features/cart/server";
|
|
1556
1557
|
export { getCart } from "./features/cart/server";
|
|
1557
1558
|
export { mergeGuestCart } from "./features/cart/server";
|
package/dist/index.js
CHANGED
|
@@ -2912,7 +2912,11 @@ export { updateBlogPostSchema } from "./features/blog/server";
|
|
|
2912
2912
|
// [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
2913
2913
|
// addItemToCart - Helper for add item to cart.
|
|
2914
2914
|
// assertCanAddNewItems - the lane gate every add-to-cart entry point must run.
|
|
2915
|
-
|
|
2915
|
+
// addGroupLineToCart - add a buyer-assembled group selection as ONE line.
|
|
2916
|
+
export { addItemToCart, assertCanAddNewItems, addGroupLineToCart, updateCartGroupMembers, GROUP_LINE_MAX_MEMBERS, } from "./features/cart/server";
|
|
2917
|
+
// Public allow-list projection for a product-group member — see
|
|
2918
|
+
// § "Public Data Projections".
|
|
2919
|
+
export { toPublicGroupMember, toPublicGroupMembers, } from "./_internal/server/features/products/group-members";
|
|
2916
2920
|
// [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
|
|
2917
2921
|
// clearCart - Shared export for clear cart.
|
|
2918
2922
|
export { clearCart } from "./features/cart/server";
|