@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.
- package/dist/_internal/server/features/bundles/actions.d.ts +4 -1
- package/dist/_internal/server/features/bundles/actions.js +54 -5
- package/dist/_internal/server/features/checkout/actions.js +44 -73
- 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/expand-order-items.d.ts +35 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +85 -0
- package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
- package/dist/_internal/server/features/grouped/index.d.ts +2 -0
- package/dist/_internal/server/features/grouped/index.js +2 -0
- package/dist/_internal/server/features/grouped/metadata.d.ts +17 -0
- package/dist/_internal/server/features/grouped/metadata.js +48 -0
- package/dist/_internal/server/features/grouped/og.d.ts +16 -0
- package/dist/_internal/server/features/grouped/og.js +24 -0
- 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/server/features/seo/sitemap.js +24 -1
- 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/order-math.d.ts +63 -4
- package/dist/_internal/shared/checkout/order-math.js +119 -7
- package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
- package/dist/client.d.ts +9 -3
- package/dist/client.js +4 -2
- package/dist/constants/api-endpoints.d.ts +21 -0
- package/dist/constants/api-endpoints.js +7 -0
- package/dist/features/admin/schemas/firestore.d.ts +10 -26
- package/dist/features/admin/schemas/firestore.js +10 -26
- package/dist/features/analytics/types.d.ts +1 -1
- package/dist/features/analytics/types.js +3 -0
- 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/components/CartGroupLineRow.d.ts +42 -0
- package/dist/features/cart/components/CartGroupLineRow.js +33 -0
- package/dist/features/cart/components/index.d.ts +2 -0
- package/dist/features/cart/components/index.js +1 -0
- package/dist/features/cart/repository/cart.repository.d.ts +33 -1
- package/dist/features/cart/repository/cart.repository.js +152 -2
- package/dist/features/cart/schemas/firestore.d.ts +7 -2
- package/dist/features/categories/components/BundleBuyNowCta.d.ts +18 -5
- package/dist/features/categories/components/BundleBuyNowCta.js +24 -10
- package/dist/features/categories/components/BundleDetailView.d.ts +8 -1
- package/dist/features/categories/components/BundleDetailView.js +2 -2
- package/dist/features/categories/components/BundlesListView.d.ts +1 -0
- package/dist/features/categories/components/CategoryBundlesListing.d.ts +3 -0
- package/dist/features/grouped/components/GroupedListingDetailView.d.ts +21 -0
- package/dist/features/grouped/components/GroupedListingDetailView.js +17 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.js +6 -1
- package/dist/features/orders/index.d.ts +1 -1
- package/dist/features/orders/index.js +1 -1
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/orders/utils/bundle-grouping.d.ts +29 -19
- package/dist/features/orders/utils/bundle-grouping.js +32 -22
- 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/features/tester/seed-data/categories-tester-seed-data.js +6 -0
- package/dist/features/tester/seed-data/products-tester-seed-data.js +44 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +33 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -2
- package/dist/next/routing/route-map.d.ts +12 -0
- package/dist/next/routing/route-map.js +6 -0
- 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-entry.d.ts +1 -1
- package/dist/server-entry.js +1 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +5 -1
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -147,6 +291,12 @@ export class CartRepository extends BaseRepository {
|
|
|
147
291
|
throw new NotFoundError(ERR_CART_ITEM_NOT_FOUND);
|
|
148
292
|
if (cart.items[itemIndex].locked)
|
|
149
293
|
throw new ValidationError(ERR_CART_ITEM_LOCKED);
|
|
294
|
+
// A grouped line's quantity is pinned to 1 — the member quantities carry
|
|
295
|
+
// the whole selection, so a copies multiplier on top would double every
|
|
296
|
+
// price, tax and stock decrement. Edit its members instead.
|
|
297
|
+
if (cart.items[itemIndex].lineKind === "group") {
|
|
298
|
+
throw new ValidationError("Change the quantities of the individual items in this group instead.");
|
|
299
|
+
}
|
|
150
300
|
const items = [...cart.items];
|
|
151
301
|
items[itemIndex] = {
|
|
152
302
|
...items[itemIndex],
|
|
@@ -48,8 +48,13 @@ export interface CartLineMember {
|
|
|
48
48
|
unitPrice: number;
|
|
49
49
|
title: string;
|
|
50
50
|
image?: string;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Snapshot for per-member GST proration at checkout (`lineTaxComponentsFor`).
|
|
53
|
+
* Same literal union as `ProductDocument.gstRate` on purpose — a loose
|
|
54
|
+
* `number` here would not survive assignment to `OrderDocumentItem` and would
|
|
55
|
+
* only be fixed by a cast, which is how a wrong rate reaches an invoice.
|
|
56
|
+
*/
|
|
57
|
+
gstRate?: 0 | 5 | 12 | 18 | 28;
|
|
53
58
|
hsnCode?: string;
|
|
54
59
|
listingType?: ListingType;
|
|
55
60
|
}
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BundleBuyNowCta —
|
|
2
|
+
* BundleBuyNowCta — purchase CTAs for bundle detail pages.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* A bundle is ALL-OR-NOTHING: its members are not pickable, and the only
|
|
5
|
+
* quantity the buyer controls is how many COPIES of the whole bundle they want.
|
|
6
|
+
* That is why there is one stepper here and no per-member controls anywhere on
|
|
7
|
+
* the bundle page — the mirror image of `GroupMemberPicker`, which has
|
|
8
|
+
* per-member steppers and no line-level one.
|
|
9
|
+
*
|
|
10
|
+
* Two CTAs, not one. "Buy now" adds and jumps straight to checkout (the
|
|
11
|
+
* pre-existing express path). "Add to cart" adds and stays — without it a
|
|
12
|
+
* bundle could never sit in the cart to be edited, because the only route in
|
|
13
|
+
* pushed the buyer past the cart immediately.
|
|
7
14
|
*/
|
|
8
15
|
import React from "react";
|
|
9
16
|
export interface BundleBuyNowCtaProps {
|
|
@@ -11,8 +18,14 @@ export interface BundleBuyNowCtaProps {
|
|
|
11
18
|
outOfStock?: boolean;
|
|
12
19
|
onBuyNow: (input: {
|
|
13
20
|
bundleSlug: string;
|
|
21
|
+
quantity: number;
|
|
22
|
+
}) => Promise<unknown>;
|
|
23
|
+
/** Adds without leaving the page. Omit to render Buy-now only. */
|
|
24
|
+
onAddToCart?: (input: {
|
|
25
|
+
bundleSlug: string;
|
|
26
|
+
quantity: number;
|
|
14
27
|
}) => Promise<unknown>;
|
|
15
28
|
/** Renders a smaller single-line button suited for list cards. */
|
|
16
29
|
compact?: boolean;
|
|
17
30
|
}
|
|
18
|
-
export declare function BundleBuyNowCta({ bundleSlug, outOfStock, onBuyNow, compact, }: BundleBuyNowCtaProps): React.JSX.Element;
|
|
31
|
+
export declare function BundleBuyNowCta({ bundleSlug, outOfStock, onBuyNow, onAddToCart, compact, }: BundleBuyNowCtaProps): React.JSX.Element;
|
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
/**
|
|
4
|
-
* BundleBuyNowCta —
|
|
4
|
+
* BundleBuyNowCta — purchase CTAs for bundle detail pages.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* A bundle is ALL-OR-NOTHING: its members are not pickable, and the only
|
|
7
|
+
* quantity the buyer controls is how many COPIES of the whole bundle they want.
|
|
8
|
+
* That is why there is one stepper here and no per-member controls anywhere on
|
|
9
|
+
* the bundle page — the mirror image of `GroupMemberPicker`, which has
|
|
10
|
+
* per-member steppers and no line-level one.
|
|
11
|
+
*
|
|
12
|
+
* Two CTAs, not one. "Buy now" adds and jumps straight to checkout (the
|
|
13
|
+
* pre-existing express path). "Add to cart" adds and stays — without it a
|
|
14
|
+
* bundle could never sit in the cart to be edited, because the only route in
|
|
15
|
+
* pushed the buyer past the cart immediately.
|
|
9
16
|
*/
|
|
10
17
|
import { useState, useCallback } from "react";
|
|
11
|
-
import { Button, LoginRequiredModal, Stack, Text } from "../../../ui";
|
|
18
|
+
import { Button, LoginRequiredModal, Row, Stack, Text } from "../../../ui";
|
|
19
|
+
import { QuantityStepper } from "../../../ui/components/QuantityStepper";
|
|
12
20
|
import { useToast } from "../../../ui";
|
|
13
21
|
import { isAuthError } from "../../../utils/auth-error";
|
|
22
|
+
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
14
23
|
import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy";
|
|
15
24
|
import { normalizeError } from "../../../errors/normalize";
|
|
16
|
-
export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, compact = false, }) {
|
|
25
|
+
export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, onAddToCart, compact = false, }) {
|
|
17
26
|
const { showToast } = useToast();
|
|
18
27
|
const [submitting, setSubmitting] = useState(false);
|
|
28
|
+
const [quantity, setQuantity] = useState(1);
|
|
19
29
|
const [error, setError] = useState(null);
|
|
20
30
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
21
|
-
const
|
|
31
|
+
const run = useCallback(async (fn, successMsg) => {
|
|
22
32
|
setError(null);
|
|
23
33
|
setSubmitting(true);
|
|
24
34
|
try {
|
|
25
|
-
await
|
|
35
|
+
await fn({ bundleSlug, quantity });
|
|
36
|
+
if (successMsg)
|
|
37
|
+
showToast(successMsg, "success");
|
|
26
38
|
}
|
|
27
39
|
catch (err) {
|
|
28
40
|
void normalizeError(err);
|
|
@@ -38,9 +50,11 @@ export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, comp
|
|
|
38
50
|
finally {
|
|
39
51
|
setSubmitting(false);
|
|
40
52
|
}
|
|
41
|
-
}, [bundleSlug,
|
|
53
|
+
}, [bundleSlug, quantity, showToast]);
|
|
54
|
+
const handleClick = useCallback(() => run(onBuyNow), [run, onBuyNow]);
|
|
55
|
+
const handleAddToCart = useCallback(() => (onAddToCart ? run(onAddToCart, "Bundle added to your cart.") : Promise.resolve()), [run, onAddToCart]);
|
|
42
56
|
if (outOfStock) {
|
|
43
57
|
return (_jsxs(Stack, { gap: "xs", "aria-live": "polite", children: [_jsx(Button, { variant: "primary", size: compact ? "sm" : "md", disabled: true, "aria-disabled": true, className: compact ? "w-full" : undefined, children: BUNDLE_COPY.detail.ctaOutOfStock }), !compact && (_jsx(Text, { size: "xs", color: "muted", children: BUNDLE_COPY.detail.ctaHint }))] }));
|
|
44
58
|
}
|
|
45
|
-
return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to purchase this bundle. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", size: compact ? "sm" : "md", onClick: handleClick, disabled: submitting, "aria-busy": submitting, className: compact ? "w-full" : undefined, children: submitting ? BUNDLE_COPY.detail.ctaAdding : BUNDLE_COPY.detail.ctaBuyNow }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error }))] })] }));
|
|
59
|
+
return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to purchase this bundle. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "sm", children: [!compact && (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Text, { size: "sm", color: "muted", children: "Copies" }), _jsx(QuantityStepper, { value: quantity, onChange: setQuantity, min: 1, max: 99, disabled: submitting, size: "md", ariaLabel: "Number of copies of this bundle", decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })] })), _jsxs(Row, { gap: "sm", wrap: true, className: compact ? "w-full" : undefined, children: [_jsx(Button, { variant: "primary", size: compact ? "sm" : "md", onClick: handleClick, disabled: submitting, "aria-busy": submitting, className: compact ? "w-full" : undefined, children: submitting ? BUNDLE_COPY.detail.ctaAdding : BUNDLE_COPY.detail.ctaBuyNow }), onAddToCart && !compact && (_jsx(Button, { action: ACTIONS.CART["add-bundle-to-cart"], variant: "outline", size: "md", onClick: handleAddToCart, disabled: submitting, "aria-busy": submitting }))] }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error }))] })] }));
|
|
46
60
|
}
|
|
@@ -17,8 +17,15 @@ export interface BundleDetailViewProps {
|
|
|
17
17
|
/** Direct-checkout callback (S-SBUNI-RULES). When supplied, renders the "Buy now" CTA. */
|
|
18
18
|
onBuyNow?: (input: {
|
|
19
19
|
bundleSlug: string;
|
|
20
|
+
quantity: number;
|
|
21
|
+
}) => Promise<unknown>;
|
|
22
|
+
/** Adds the bundle without leaving the page — the only way it can reach the
|
|
23
|
+
* cart and stay there to be edited. */
|
|
24
|
+
onAddToCart?: (input: {
|
|
25
|
+
bundleSlug: string;
|
|
26
|
+
quantity: number;
|
|
20
27
|
}) => Promise<unknown>;
|
|
21
28
|
/** Other active bundles, excluding this one — renders a "Related Bundles" section when non-empty. */
|
|
22
29
|
relatedBundles?: CategoryDocument[];
|
|
23
30
|
}
|
|
24
|
-
export declare function BundleDetailView({ bundle, members, onBuyNow, relatedBundles, }: BundleDetailViewProps): React.JSX.Element;
|
|
31
|
+
export declare function BundleDetailView({ bundle, members, onBuyNow, onAddToCart, relatedBundles, }: BundleDetailViewProps): React.JSX.Element;
|
|
@@ -17,7 +17,7 @@ const STOCK_BADGE_TEXT = {
|
|
|
17
17
|
out_of_stock: BUNDLE_COPY.stockBadge.out_of_stock,
|
|
18
18
|
};
|
|
19
19
|
const PLACEHOLDER_EMOJI = "📦";
|
|
20
|
-
export function BundleDetailView({ bundle, members = [], onBuyNow, relatedBundles = [], }) {
|
|
20
|
+
export function BundleDetailView({ bundle, members = [], onBuyNow, onAddToCart, relatedBundles = [], }) {
|
|
21
21
|
const memberCount = members.length || bundle.bundleProductIds?.length || 0;
|
|
22
22
|
const stock = bundle.bundleStockStatus ?? "in_stock";
|
|
23
23
|
const cover = bundle.display?.coverImage;
|
|
@@ -25,5 +25,5 @@ export function BundleDetailView({ bundle, members = [], onBuyNow, relatedBundle
|
|
|
25
25
|
? formatCurrency(bundle.bundlePrice, "INR")
|
|
26
26
|
: BUNDLE_COPY.detail.priceFallback;
|
|
27
27
|
const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
|
|
28
|
-
return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
|
|
28
|
+
return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow, onAddToCart: onAddToCart })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
|
|
29
29
|
}
|
|
@@ -3,8 +3,11 @@ import type { CategoryDocument } from "../schemas";
|
|
|
3
3
|
export interface CategoryBundlesListingProps {
|
|
4
4
|
initialBundles: CategoryDocument[];
|
|
5
5
|
brandName?: string;
|
|
6
|
+
/** Cards render the CTA in `compact` mode, which has no stepper — quantity
|
|
7
|
+
* is always 1 from here. The full stepper lives on the bundle detail page. */
|
|
6
8
|
onBuyNow?: (input: {
|
|
7
9
|
bundleSlug: string;
|
|
10
|
+
quantity: number;
|
|
8
11
|
}) => Promise<unknown>;
|
|
9
12
|
}
|
|
10
13
|
export declare function CategoryBundlesListing({ initialBundles, brandName, onBuyNow, }: CategoryBundlesListingProps): React.JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GroupedListingDetailView — the public page for a `groupedListings` document.
|
|
3
|
+
*
|
|
4
|
+
* Before this, a grouped listing had no page at all: it existed only as a
|
|
5
|
+
* horizontal carousel on other pages, so there was nowhere to see the whole
|
|
6
|
+
* group and nothing to link to from a cart line built out of it.
|
|
7
|
+
*
|
|
8
|
+
* A grouped listing has NO price of its own — SB-UNI-V removed its pricing
|
|
9
|
+
* fields on purpose. That is not a gap to fill: under "pick as you wish" the
|
|
10
|
+
* price simply IS the sum of what the buyer selected, which is exactly what
|
|
11
|
+
* `GroupMemberPicker` computes and what `unitPriceFor` recomputes server-side.
|
|
12
|
+
*/
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { type GroupPickerMember } from "../../products/components/GroupMemberPicker";
|
|
15
|
+
import type { GroupedListingDocument } from "../schemas/firestore";
|
|
16
|
+
export interface GroupedListingDetailViewProps {
|
|
17
|
+
group: GroupedListingDocument;
|
|
18
|
+
/** Members, already projected for public consumption. */
|
|
19
|
+
members: GroupPickerMember[];
|
|
20
|
+
}
|
|
21
|
+
export declare function GroupedListingDetailView({ group, members }: GroupedListingDetailViewProps): React.JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Container, Div, Heading, Section, Span, Stack, Text, } from "../../../ui";
|
|
4
|
+
import { MediaImage } from "../../media/MediaImage";
|
|
5
|
+
import { GroupMemberPicker } from "../../products/components/GroupMemberPicker";
|
|
6
|
+
const __O = { hidden: "overflow-hidden" };
|
|
7
|
+
/** Human label per theme — mirrors GROUP_THEME_TITLE on the carousel. */
|
|
8
|
+
const THEME_LABEL = {
|
|
9
|
+
related: "Related items",
|
|
10
|
+
character: "Same character",
|
|
11
|
+
lineage: "Same lineage",
|
|
12
|
+
set: "Complete the set",
|
|
13
|
+
generic: "Grouped listing",
|
|
14
|
+
};
|
|
15
|
+
export function GroupedListingDetailView({ group, members }) {
|
|
16
|
+
return (_jsx(Section, { padding: "section", children: _jsx(Container, { children: _jsxs(Stack, { gap: "comfortable", children: [group.coverImage && (_jsx(Div, { className: `relative h-48 w-full md:h-64 ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: group.coverImage, alt: group.title, size: "banner" }) })), _jsxs(Stack, { gap: "xs", children: [_jsx(Span, { size: "xs", color: "muted", transform: "uppercase", weight: "semibold", children: THEME_LABEL[group.groupTheme] }), _jsx(Heading, { level: 1, children: group.title }), group.description && (_jsx(Text, { color: "muted", children: group.description })), _jsxs(Text, { size: "sm", color: "muted", children: [members.length, " item", members.length === 1 ? "" : "s", " in this group"] })] }), members.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "lg", children: "Pick what you want" }), _jsx(Text, { size: "sm", color: "muted", children: "Choose a quantity for each item \u2014 they'll go into your cart as a single line you can edit later." }), _jsx(GroupMemberPicker, { groupId: group.id, groupSource: "grouped-listing", members: members })] })) : (_jsx(Text, { color: "muted", children: "Nothing in this group is available right now." }))] }) }) }));
|
|
17
|
+
}
|
|
@@ -9,6 +9,7 @@ import { ProductCard } from "../../products/components/ProductGrid";
|
|
|
9
9
|
import { toProductItem } from "../../../_internal/shared/features/products/to-product-item";
|
|
10
10
|
import { CAROUSEL_PER_VIEW } from "../../homepage/constants/carousel-per-view";
|
|
11
11
|
import { Stack } from "../../../ui";
|
|
12
|
+
import { ROUTES } from "../../../next/routing/route-map";
|
|
12
13
|
const GROUP_THEME_TITLE = {
|
|
13
14
|
related: "Other listings you might like",
|
|
14
15
|
character: "Same character",
|
|
@@ -29,6 +30,10 @@ export function GroupedListingsCarousel({ groups }) {
|
|
|
29
30
|
return null;
|
|
30
31
|
return (_jsx(Stack, { gap: "xl", children: visibleGroups.map((group) => {
|
|
31
32
|
const items = group.items.map((p) => toProductItem(p));
|
|
32
|
-
return (_jsx(SectionCarousel, { title: group.title || GROUP_THEME_TITLE[group.groupTheme], pillLabel: "Grouped Listings", headingVariant: "editorial",
|
|
33
|
+
return (_jsx(SectionCarousel, { title: group.title || GROUP_THEME_TITLE[group.groupTheme], pillLabel: "Grouped Listings", headingVariant: "editorial",
|
|
34
|
+
// The group now has a page of its own, where the buyer can pick
|
|
35
|
+
// several members and add them as one cart line. Until it existed,
|
|
36
|
+
// this carousel was the only way a grouped listing was ever seen.
|
|
37
|
+
viewMoreHref: group.slug ? String(ROUTES.PUBLIC.GROUP_DETAIL(group.slug)) : undefined, viewMoreLabel: "Pick items from this group \u2192", items: items, isLoading: false, perView: CAROUSEL_PER_VIEW.standard, keyExtractor: (item) => item.id, renderItem: (item) => (_jsx(ProductCard, { product: item, href: item.slug || item.id ? pluginFor(item.listingType ?? "standard").detailRoute(item.slug || item.id) : undefined })) }, group.id));
|
|
33
38
|
}) }));
|
|
34
39
|
}
|
|
@@ -4,5 +4,5 @@ export * from "./components";
|
|
|
4
4
|
export * from "./schemas";
|
|
5
5
|
export * from "./columns";
|
|
6
6
|
export * from "./utils/order-splitter";
|
|
7
|
-
export {
|
|
7
|
+
export { groupOrderItemsByLine, type LineOrderGroup, type OrderItemForLineGrouping, } from "./utils/bundle-grouping";
|
|
8
8
|
export { manifest } from "./manifest";
|
|
@@ -5,5 +5,5 @@ export * from "./schemas";
|
|
|
5
5
|
export * from "./columns";
|
|
6
6
|
export * from "./utils/order-splitter";
|
|
7
7
|
// S-SBUNI-5 2026-05-13 — bundle-aware order item grouping.
|
|
8
|
-
export {
|
|
8
|
+
export { groupOrderItemsByLine, } from "./utils/bundle-grouping";
|
|
9
9
|
export { manifest } from "./manifest";
|
|
@@ -188,7 +188,18 @@ export interface OrderDocumentItem {
|
|
|
188
188
|
cancelledReason?: string;
|
|
189
189
|
/** SB-UNI-5 — forwarded from the cart line when this order item is an expanded bundle, so the order-detail UI can collapse it back under a "Bundle: <name>" header and stock-restore logic can fan back out to member products. */
|
|
190
190
|
bundleCategorySlug?: string;
|
|
191
|
+
/** @deprecated Legacy collapsed rows only — expanded rows carry one product each. */
|
|
191
192
|
bundleProductIds?: string[];
|
|
193
|
+
/**
|
|
194
|
+
* Collapse key for a multi-member cart line, shared by every row the line
|
|
195
|
+
* expanded into. `groupOrderItemsByLine` groups on it so the receipt still
|
|
196
|
+
* reads as one "Bundle: X" / "Group: X" block.
|
|
197
|
+
*/
|
|
198
|
+
groupSlug?: string;
|
|
199
|
+
/** Display name of the bundle / group these rows came from. */
|
|
200
|
+
groupTitle?: string;
|
|
201
|
+
/** Which kind of multi-member line produced these rows. */
|
|
202
|
+
groupSource?: "bundle" | "product-group" | "grouped-listing";
|
|
192
203
|
/** P-8 GST — snapshotted from the product at order time so the invoice stays accurate even if the product's HSN/rate later changes. */
|
|
193
204
|
hsnCode?: string;
|
|
194
205
|
gstRate?: 0 | 5 | 12 | 18 | 28;
|
|
@@ -7,42 +7,52 @@
|
|
|
7
7
|
* checkout receipt mirrors the buyer's intent. This helper is the pure
|
|
8
8
|
* grouping logic — the UI primitive lives next to the consumer view.
|
|
9
9
|
*
|
|
10
|
-
* Two flavours of
|
|
11
|
-
* 1. ONE
|
|
12
|
-
* describe the bundle
|
|
13
|
-
* bundle
|
|
14
|
-
*
|
|
15
|
-
* 2.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* Two flavours of multi-member row can appear on an order:
|
|
11
|
+
* 1. LEGACY — ONE row whose `bundleCategorySlug` + `bundleProductIds[]`
|
|
12
|
+
* describe the whole bundle, with the locked bundlePrice as its line
|
|
13
|
+
* total. Every bundle order placed before row expansion looks like this,
|
|
14
|
+
* and they keep rendering unchanged.
|
|
15
|
+
* 2. CURRENT — expanded N rows, one per member, all sharing a `groupSlug`
|
|
16
|
+
* (and, for bundles, the same `bundleCategorySlug`). This is what
|
|
17
|
+
* checkout writes now, so each row can carry its own HSN/GST and be
|
|
18
|
+
* cancelled independently.
|
|
18
19
|
*
|
|
19
|
-
* Either way
|
|
20
|
-
*
|
|
20
|
+
* Either way this returns a stable, ordered list of groups so the renderer
|
|
21
|
+
* iterates once and the buyer's receipt reads the same.
|
|
21
22
|
*/
|
|
22
23
|
/** Minimal shape consumed from an order item (Firestore wire format). */
|
|
23
|
-
export interface
|
|
24
|
-
/**
|
|
24
|
+
export interface OrderItemForLineGrouping {
|
|
25
|
+
/** Collapse key for expanded rows (bundle OR buyer-assembled group). */
|
|
26
|
+
groupSlug?: string;
|
|
27
|
+
/** Display name of the bundle / group. */
|
|
28
|
+
groupTitle?: string;
|
|
29
|
+
/** Bundle discriminator — also set on expanded bundle rows. */
|
|
25
30
|
bundleCategorySlug?: string;
|
|
26
|
-
/**
|
|
31
|
+
/** @deprecated Legacy collapsed rows only. */
|
|
27
32
|
bundleProductIds?: string[];
|
|
28
33
|
}
|
|
29
|
-
export type
|
|
34
|
+
export type LineOrderGroup<T extends OrderItemForLineGrouping> = {
|
|
30
35
|
kind: "single";
|
|
31
36
|
item: T;
|
|
32
37
|
index: number;
|
|
33
38
|
} | {
|
|
34
39
|
kind: "bundle";
|
|
40
|
+
/** The key these rows were collapsed on. */
|
|
35
41
|
bundleCategorySlug: string;
|
|
42
|
+
groupTitle?: string;
|
|
36
43
|
items: Array<{
|
|
37
44
|
item: T;
|
|
38
45
|
index: number;
|
|
39
46
|
}>;
|
|
40
|
-
/**
|
|
47
|
+
/** Members represented: the row count when expanded, the snapshot length when legacy. */
|
|
41
48
|
memberCount: number;
|
|
42
49
|
};
|
|
43
50
|
/**
|
|
44
|
-
* Walk the items array preserving original order; collapse
|
|
45
|
-
*
|
|
46
|
-
*
|
|
51
|
+
* Walk the items array preserving original order; collapse rows sharing a
|
|
52
|
+
* collapse key into one group. Single rows pass through unchanged.
|
|
53
|
+
*
|
|
54
|
+
* The key is `groupSlug ?? bundleCategorySlug` — the fallback is what keeps
|
|
55
|
+
* legacy collapsed bundle rows (written before `groupSlug` existed) grouping
|
|
56
|
+
* exactly as they always did, with no order migration.
|
|
47
57
|
*/
|
|
48
|
-
export declare function
|
|
58
|
+
export declare function groupOrderItemsByLine<T extends OrderItemForLineGrouping>(items: T[]): Array<LineOrderGroup<T>>;
|