@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
@@ -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 && !item.offerId && !item.bidId);
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
- /** Snapshot for per-member GST proration at checkout (`lineTaxComponentsFor`). */
52
- gstRate?: number;
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 — direct-checkout CTA for bundle detail pages.
2
+ * BundleBuyNowCta — purchase CTAs for bundle detail pages.
3
3
  *
4
- * Replaces BundleAddToCartCta (deleted). Bundles bypass the cart and go
5
- * straight to checkout via the `directBundleCheckoutAction` server action.
6
- * No qty input — bundles are always qty=1 per checkout.
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 — direct-checkout CTA for bundle detail pages.
4
+ * BundleBuyNowCta — purchase CTAs for bundle detail pages.
5
5
  *
6
- * Replaces BundleAddToCartCta (deleted). Bundles bypass the cart and go
7
- * straight to checkout via the `directBundleCheckoutAction` server action.
8
- * No qty input — bundles are always qty=1 per checkout.
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 handleClick = useCallback(async () => {
31
+ const run = useCallback(async (fn, successMsg) => {
22
32
  setError(null);
23
33
  setSubmitting(true);
24
34
  try {
25
- await onBuyNow({ bundleSlug });
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, onBuyNow, showToast]);
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
  }
@@ -4,6 +4,7 @@ export interface BundlesListViewProps {
4
4
  searchParams?: SearchParams;
5
5
  onBuyNow?: (input: {
6
6
  bundleSlug: string;
7
+ quantity: number;
7
8
  }) => Promise<unknown>;
8
9
  }
9
10
  /**
@@ -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", 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
+ 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 { groupOrderItemsByBundle, type BundleOrderGroup, type OrderItemForBundleGrouping, } from "./utils/bundle-grouping";
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 { groupOrderItemsByBundle, } from "./utils/bundle-grouping";
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 "bundle row" can appear on an order:
11
- * 1. ONE order row whose `bundleCategorySlug` + `bundleProductIds[]` set
12
- * describe the bundle (this is what S-SBUNI-4's checkout writes — the
13
- * bundle stays a single row in the order with the locked
14
- * bundlePrice as its line total).
15
- * 2. (Future) Expanded N rows, each tagged with the same
16
- * `bundleCategorySlug`. The grouping handles this too in case a future
17
- * session pivots to row expansion.
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, `groupOrderItemsByBundle` returns a stable, ordered list of
20
- * "groups" so the renderer can iterate once.
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 OrderItemForBundleGrouping {
24
- /** Bundle discriminator — set when this row is part of a bundle. */
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
- /** Snapshot of bundle members at order-creation time. */
31
+ /** @deprecated Legacy collapsed rows only. */
27
32
  bundleProductIds?: string[];
28
33
  }
29
- export type BundleOrderGroup<T extends OrderItemForBundleGrouping> = {
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
- /** Pulled from the first row in the group; reflects what checkout snapshotted. */
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 contiguous (or
45
- * non-contiguous) bundle rows sharing the same `bundleCategorySlug` into a
46
- * single group. Single rows pass through unchanged.
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 groupOrderItemsByBundle<T extends OrderItemForBundleGrouping>(items: T[]): Array<BundleOrderGroup<T>>;
58
+ export declare function groupOrderItemsByLine<T extends OrderItemForLineGrouping>(items: T[]): Array<LineOrderGroup<T>>;