@mohasinac/appkit 4.25.0 → 4.27.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 (58) 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/bundles/data.d.ts +19 -0
  4. package/dist/_internal/server/features/bundles/data.js +32 -0
  5. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  6. package/dist/_internal/server/features/bundles/index.js +1 -1
  7. package/dist/_internal/server/features/checkout/actions.js +38 -68
  8. package/dist/_internal/server/features/checkout/expand-order-items.d.ts +35 -0
  9. package/dist/_internal/server/features/checkout/expand-order-items.js +85 -0
  10. package/dist/_internal/server/features/grouped/index.d.ts +2 -0
  11. package/dist/_internal/server/features/grouped/index.js +2 -0
  12. package/dist/_internal/server/features/grouped/metadata.d.ts +17 -0
  13. package/dist/_internal/server/features/grouped/metadata.js +48 -0
  14. package/dist/_internal/server/features/grouped/og.d.ts +16 -0
  15. package/dist/_internal/server/features/grouped/og.js +24 -0
  16. package/dist/_internal/server/features/seo/sitemap.js +24 -1
  17. package/dist/_internal/shared/checkout/order-math.d.ts +63 -4
  18. package/dist/_internal/shared/checkout/order-math.js +119 -7
  19. package/dist/client.d.ts +7 -3
  20. package/dist/client.js +3 -2
  21. package/dist/constants/api-endpoints.d.ts +0 -6
  22. package/dist/constants/api-endpoints.js +0 -2
  23. package/dist/features/analytics/types.d.ts +1 -1
  24. package/dist/features/analytics/types.js +3 -0
  25. package/dist/features/cart/components/CartGroupLineRow.d.ts +42 -0
  26. package/dist/features/cart/components/CartGroupLineRow.js +33 -0
  27. package/dist/features/cart/components/index.d.ts +2 -0
  28. package/dist/features/cart/components/index.js +1 -0
  29. package/dist/features/cart/repository/cart.repository.js +6 -0
  30. package/dist/features/cart/schemas/firestore.d.ts +7 -2
  31. package/dist/features/categories/components/BundleBuyNowCta.d.ts +18 -5
  32. package/dist/features/categories/components/BundleBuyNowCta.js +24 -10
  33. package/dist/features/categories/components/BundleDetailView.d.ts +8 -1
  34. package/dist/features/categories/components/BundleDetailView.js +2 -2
  35. package/dist/features/categories/components/BundlesListView.d.ts +1 -0
  36. package/dist/features/categories/components/CategoryBundlesListing.d.ts +3 -0
  37. package/dist/features/grouped/components/GroupedListingDetailView.d.ts +21 -0
  38. package/dist/features/grouped/components/GroupedListingDetailView.js +17 -0
  39. package/dist/features/grouped/components/GroupedListingsCarousel.js +6 -1
  40. package/dist/features/orders/index.d.ts +1 -1
  41. package/dist/features/orders/index.js +1 -1
  42. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  43. package/dist/features/orders/utils/bundle-grouping.d.ts +29 -19
  44. package/dist/features/orders/utils/bundle-grouping.js +32 -22
  45. package/dist/features/tester/seed-data/categories-tester-seed-data.js +6 -0
  46. package/dist/features/tester/seed-data/products-tester-seed-data.js +150 -1
  47. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +118 -0
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +1 -1
  50. package/dist/next/routing/route-map.d.ts +12 -0
  51. package/dist/next/routing/route-map.js +6 -0
  52. package/dist/server-entry.d.ts +1 -1
  53. package/dist/server-entry.js +1 -1
  54. package/dist/server.d.ts +3 -1
  55. package/dist/server.js +5 -1
  56. package/dist/styles.css +1 -1
  57. package/dist/tailwind-utilities.css +1 -1
  58. package/package.json +1 -1
@@ -210,6 +210,27 @@ async function fetchCategoryTypeUrls(baseUrl, categoryType, buildUrl, label) {
210
210
  const fetchCategoryUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "listing", (slug) => ROUTES.PUBLIC.CATEGORY_DETAIL(slug), "category");
211
211
  const fetchBrandUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "brand", (slug) => ROUTES.PUBLIC.BRAND_DETAIL(slug), "brand");
212
212
  const fetchBundleUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "bundle", (slug) => ROUTES.PUBLIC.BUNDLE_DETAIL(slug), "bundle");
213
+ /**
214
+ * `groupedListings` documents. `listSitemapGroupedListings` has existed since
215
+ * the feature was built, but there was no public page for it to point at until
216
+ * `/groups/{slug}` — so it was never wired into the sitemap.
217
+ */
218
+ async function fetchGroupedListingUrls(baseUrl) {
219
+ try {
220
+ const { listSitemapGroupedListings } = await import("../grouped/data");
221
+ const groups = await listSitemapGroupedListings();
222
+ return groups.map((g) => ({
223
+ url: `${baseUrl}${ROUTES.PUBLIC.GROUP_DETAIL(g.slug)}`,
224
+ lastModified: g.updatedAt,
225
+ changeFrequency: "weekly",
226
+ priority: 0.5,
227
+ }));
228
+ }
229
+ catch (err) {
230
+ void normalizeError(err);
231
+ return [];
232
+ }
233
+ }
213
234
  async function fetchBlogPostUrls(baseUrl) {
214
235
  try {
215
236
  const db = getAdminDb();
@@ -293,11 +314,12 @@ async function fetchScammerUrls(baseUrl) {
293
314
  }
294
315
  }
295
316
  export async function buildSitemap({ baseUrl }) {
296
- const [productUrls, categoryUrls, brandUrls, bundleUrls, eventUrls, blogUrls, auctionUrls, preOrderUrls, prizeDrawUrls, classifiedUrls, digitalCodeUrls, liveUrls, storeUrls, scammerUrls,] = await Promise.all([
317
+ const [productUrls, categoryUrls, brandUrls, bundleUrls, groupedListingUrls, eventUrls, blogUrls, auctionUrls, preOrderUrls, prizeDrawUrls, classifiedUrls, digitalCodeUrls, liveUrls, storeUrls, scammerUrls,] = await Promise.all([
297
318
  fetchProductUrls(baseUrl),
298
319
  fetchCategoryUrls(baseUrl),
299
320
  fetchBrandUrls(baseUrl),
300
321
  fetchBundleUrls(baseUrl),
322
+ fetchGroupedListingUrls(baseUrl),
301
323
  fetchEventUrls(baseUrl),
302
324
  fetchBlogPostUrls(baseUrl),
303
325
  fetchAuctionUrls(baseUrl),
@@ -314,6 +336,7 @@ export async function buildSitemap({ baseUrl }) {
314
336
  ...categoryUrls,
315
337
  ...brandUrls,
316
338
  ...bundleUrls,
339
+ ...groupedListingUrls,
317
340
  ...blogUrls,
318
341
  ...productUrls,
319
342
  ...auctionUrls,
@@ -17,13 +17,14 @@ import type { ProductDocument } from "../../../features/products/schemas/firesto
17
17
  * copies of this function looked at `lockedPrice`, so an accepted offer was
18
18
  * billed at the seller's listed price while the cart UI displayed the agreed
19
19
  * one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
20
- * 2. **A multi-member line** — the sum of its members' own snapshotted prices,
20
+ * 2. **A bundle line** — `item.price` is the bundle's own locked price, which
21
+ * is deliberately LESS than the sum of its members. Checked before the
22
+ * members branch for exactly that reason. Covers both modern bundle lines
23
+ * (`lineKind: "bundle"`) and legacy ones (SB-UNI-5 2026-05-13).
24
+ * 3. **A grouped line** — the sum of its members' own snapshotted prices,
21
25
  * weighted by each member's per-copy quantity. A `"group"` line pins
22
26
  * `item.quantity` to 1, so this is both the unit price and the line total;
23
27
  * see the invariant on `CartLineMember`.
24
- * 3. **A legacy bundle line** (SB-UNI-5 2026-05-13) — written before
25
- * `groupMembers` existed. `item.price` is the bundle price locked at
26
- * add-time.
27
28
  * 4. **Everything else** — the live Firestore price, so a stale cart-cached
28
29
  * price is never charged on a COD/UPI order.
29
30
  *
@@ -44,6 +45,64 @@ export declare function unitPriceFor(item: CartItemDocument, product: ProductDoc
44
45
  * itself — that multiplication is where the six hand-rolled copies diverged.
45
46
  */
46
47
  export declare function lineTotalFor(item: CartItemDocument, product: ProductDocument | null): number;
48
+ /** One taxable slice of a line. A single-product line has exactly one. */
49
+ export interface LineTaxComponent {
50
+ productId: string;
51
+ /** Rupees this slice is taxed on. Slices of a line sum to its line total. */
52
+ taxable: number;
53
+ gstRate: number;
54
+ hsnCode?: string;
55
+ }
56
+ /**
57
+ * Split a line into its taxable slices.
58
+ *
59
+ * GST cannot go through `unitPriceFor`: that returns a scalar, and a
60
+ * multi-member line genuinely has SEVERAL rates — a ₹200 sticker at 12% next to
61
+ * a ₹2,000 top at 18% inside one selection. A scalar can only ever carry one of
62
+ * them, which is why a bundle line has paid **zero** GST for its whole
63
+ * existence: `previewCheckoutPricing` looked its product up by `item.productId`,
64
+ * which on a bundle is a CATEGORY id, so `product` was null and
65
+ * `product?.gstRate ?? 0` quietly resolved to 0.
66
+ *
67
+ * Three shapes:
68
+ *
69
+ * - **Plain line** — one slice, taxed on the whole line total. Identical to
70
+ * what the two GST loops did before.
71
+ * - **Grouped line** — one slice per member, each taxed on its own
72
+ * `unitPrice × member.quantity × item.quantity`. Slices sum to the line
73
+ * total exactly, because a grouped line has no price of its own.
74
+ * - **Bundle line** — the taxable base is the bundle's LOCKED price, not the
75
+ * sum of the members' list prices, so each member's slice is a prorated
76
+ * share of it (see `allocateBundlePrice`). Charging tax on the undiscounted
77
+ * total would tax the buyer on money they never paid.
78
+ *
79
+ * `productById` supplies the live `gstRate`; each member's snapshotted rate is
80
+ * the fallback for a member whose listing has since been archived.
81
+ */
82
+ export declare function lineTaxComponentsFor(item: CartItemDocument, productById: Map<string, ProductDocument>): LineTaxComponent[];
83
+ /**
84
+ * Total GST for one order group, and the base it was charged on.
85
+ *
86
+ * Exists so `createOrderForGroup` (what the buyer is CHARGED) and
87
+ * `previewCheckoutPricing` (what the buyer is SHOWN) cannot disagree. They were
88
+ * two hand-written loops, and Root Cause #59 is precisely the pattern of fixing
89
+ * one and not the other — here that would mean quoting one tax figure and
90
+ * charging another.
91
+ */
92
+ export declare function sumGroupGst(items: readonly CartItemDocument[], productById: Map<string, ProductDocument>, intraState: boolean): {
93
+ taxableAmount: number;
94
+ gstAmount: number;
95
+ };
96
+ /**
97
+ * Split `total` across members in proportion to their list prices, with the
98
+ * rounding remainder pushed onto the LAST member so the parts sum to exactly
99
+ * `total`, to the last decimal place. Rounding each share independently leaves
100
+ * an order whose `taxableAmount` doesn't reconcile with the sum of its lines.
101
+ *
102
+ * Falls back to an even split when every weight is zero (a bundle of free
103
+ * items), which is the only case where proportion is undefined.
104
+ */
105
+ export declare function allocateAcrossMembers(total: number, weights: number[]): number[];
47
106
  /**
48
107
  * P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
49
108
  * (falling back to the generic COD deposit % when a product doesn't have one
@@ -1,4 +1,5 @@
1
1
  import { roundRupees } from "../../../utils/number.formatter";
2
+ import { calculateGst } from "../fees/calculator";
2
3
  /**
3
4
  * The single source of truth for "what do we charge for ONE COPY of this line".
4
5
  *
@@ -10,13 +11,14 @@ import { roundRupees } from "../../../utils/number.formatter";
10
11
  * copies of this function looked at `lockedPrice`, so an accepted offer was
11
12
  * billed at the seller's listed price while the cart UI displayed the agreed
12
13
  * one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
13
- * 2. **A multi-member line** — the sum of its members' own snapshotted prices,
14
+ * 2. **A bundle line** — `item.price` is the bundle's own locked price, which
15
+ * is deliberately LESS than the sum of its members. Checked before the
16
+ * members branch for exactly that reason. Covers both modern bundle lines
17
+ * (`lineKind: "bundle"`) and legacy ones (SB-UNI-5 2026-05-13).
18
+ * 3. **A grouped line** — the sum of its members' own snapshotted prices,
14
19
  * weighted by each member's per-copy quantity. A `"group"` line pins
15
20
  * `item.quantity` to 1, so this is both the unit price and the line total;
16
21
  * see the invariant on `CartLineMember`.
17
- * 3. **A legacy bundle line** (SB-UNI-5 2026-05-13) — written before
18
- * `groupMembers` existed. `item.price` is the bundle price locked at
19
- * add-time.
20
22
  * 4. **Everything else** — the live Firestore price, so a stale cart-cached
21
23
  * price is never charged on a COD/UPI order.
22
24
  *
@@ -34,12 +36,18 @@ export function unitPriceFor(item, product) {
34
36
  if (typeof item.lockedPrice === "number" && item.lockedPrice > 0) {
35
37
  return item.lockedPrice;
36
38
  }
39
+ // A BUNDLE is priced as a whole, and that price is the entire point of it
40
+ // being a bundle — it is deliberately less than the sum of its members. This
41
+ // branch must come BEFORE the members branch: a bundle line carries
42
+ // `groupMembers` too (so the cart can show what's inside and checkout can
43
+ // prorate GST), and summing them would silently charge the undiscounted total.
44
+ if (item.lineKind === "bundle" || (item.bundleCategorySlug && item.bundleProductIds?.length)) {
45
+ return item.price;
46
+ }
47
+ // A GROUP line has no price of its own — it is exactly what the buyer picked.
37
48
  if (item.groupMembers?.length) {
38
49
  return roundRupees(item.groupMembers.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0));
39
50
  }
40
- if (item.bundleCategorySlug && item.bundleProductIds?.length) {
41
- return item.price;
42
- }
43
51
  // Null-safe fallback: one of the four call sites this replaced (the cart
44
52
  // summary/preview path) already guarded with `product?.price ?? item.price`,
45
53
  // and collapsing onto a non-null assertion would have regressed it into a
@@ -54,6 +62,110 @@ export function unitPriceFor(item, product) {
54
62
  export function lineTotalFor(item, product) {
55
63
  return unitPriceFor(item, product) * item.quantity;
56
64
  }
65
+ /**
66
+ * Split a line into its taxable slices.
67
+ *
68
+ * GST cannot go through `unitPriceFor`: that returns a scalar, and a
69
+ * multi-member line genuinely has SEVERAL rates — a ₹200 sticker at 12% next to
70
+ * a ₹2,000 top at 18% inside one selection. A scalar can only ever carry one of
71
+ * them, which is why a bundle line has paid **zero** GST for its whole
72
+ * existence: `previewCheckoutPricing` looked its product up by `item.productId`,
73
+ * which on a bundle is a CATEGORY id, so `product` was null and
74
+ * `product?.gstRate ?? 0` quietly resolved to 0.
75
+ *
76
+ * Three shapes:
77
+ *
78
+ * - **Plain line** — one slice, taxed on the whole line total. Identical to
79
+ * what the two GST loops did before.
80
+ * - **Grouped line** — one slice per member, each taxed on its own
81
+ * `unitPrice × member.quantity × item.quantity`. Slices sum to the line
82
+ * total exactly, because a grouped line has no price of its own.
83
+ * - **Bundle line** — the taxable base is the bundle's LOCKED price, not the
84
+ * sum of the members' list prices, so each member's slice is a prorated
85
+ * share of it (see `allocateBundlePrice`). Charging tax on the undiscounted
86
+ * total would tax the buyer on money they never paid.
87
+ *
88
+ * `productById` supplies the live `gstRate`; each member's snapshotted rate is
89
+ * the fallback for a member whose listing has since been archived.
90
+ */
91
+ export function lineTaxComponentsFor(item, productById) {
92
+ const lineTotal = lineTotalFor(item, productById.get(item.productId) ?? null);
93
+ const members = item.groupMembers;
94
+ if (!members?.length) {
95
+ const product = productById.get(item.productId);
96
+ return [{
97
+ productId: item.productId,
98
+ taxable: lineTotal,
99
+ gstRate: product?.gstRate ?? 0,
100
+ hsnCode: product?.hsnCode,
101
+ }];
102
+ }
103
+ const rateFor = (m) => productById.get(m.productId)?.gstRate ?? m.gstRate ?? 0;
104
+ const hsnFor = (m) => productById.get(m.productId)?.hsnCode ?? m.hsnCode;
105
+ const isBundle = item.lineKind === "bundle" || Boolean(item.bundleCategorySlug);
106
+ if (!isBundle) {
107
+ return members.map((m) => ({
108
+ productId: m.productId,
109
+ taxable: roundRupees(m.unitPrice * m.quantity * item.quantity),
110
+ gstRate: rateFor(m),
111
+ hsnCode: hsnFor(m),
112
+ }));
113
+ }
114
+ const shares = allocateAcrossMembers(lineTotal, members.map((m) => m.unitPrice * m.quantity));
115
+ return members.map((m, i) => ({
116
+ productId: m.productId,
117
+ taxable: shares[i],
118
+ gstRate: rateFor(m),
119
+ hsnCode: hsnFor(m),
120
+ }));
121
+ }
122
+ /**
123
+ * Total GST for one order group, and the base it was charged on.
124
+ *
125
+ * Exists so `createOrderForGroup` (what the buyer is CHARGED) and
126
+ * `previewCheckoutPricing` (what the buyer is SHOWN) cannot disagree. They were
127
+ * two hand-written loops, and Root Cause #59 is precisely the pattern of fixing
128
+ * one and not the other — here that would mean quoting one tax figure and
129
+ * charging another.
130
+ */
131
+ export function sumGroupGst(items, productById, intraState) {
132
+ let taxableAmount = 0;
133
+ let gstAmount = 0;
134
+ for (const item of items) {
135
+ for (const slice of lineTaxComponentsFor(item, productById)) {
136
+ if (slice.gstRate <= 0)
137
+ continue;
138
+ taxableAmount += slice.taxable;
139
+ gstAmount += calculateGst(slice.gstRate, intraState, slice.taxable).gstAmount;
140
+ }
141
+ }
142
+ return { taxableAmount, gstAmount };
143
+ }
144
+ /**
145
+ * Split `total` across members in proportion to their list prices, with the
146
+ * rounding remainder pushed onto the LAST member so the parts sum to exactly
147
+ * `total`, to the last decimal place. Rounding each share independently leaves
148
+ * an order whose `taxableAmount` doesn't reconcile with the sum of its lines.
149
+ *
150
+ * Falls back to an even split when every weight is zero (a bundle of free
151
+ * items), which is the only case where proportion is undefined.
152
+ */
153
+ export function allocateAcrossMembers(total, weights) {
154
+ if (weights.length === 0)
155
+ return [];
156
+ const sum = weights.reduce((a, b) => a + b, 0);
157
+ const out = [];
158
+ let running = 0;
159
+ for (let i = 0; i < weights.length - 1; i++) {
160
+ const share = sum > 0
161
+ ? roundRupees((total * weights[i]) / sum) // audit-money-units-ok: proportional split of decimal rupees
162
+ : roundRupees(total / weights.length);
163
+ out.push(share);
164
+ running += share;
165
+ }
166
+ out.push(roundRupees(total - running));
167
+ return out;
168
+ }
57
169
  /**
58
170
  * P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
59
171
  * (falling back to the generic COD deposit % when a product doesn't have one
package/dist/client.d.ts CHANGED
@@ -225,12 +225,14 @@ export { ProtectedRoute, AuthStatusPanel, ForgotPasswordView, LoginForm, Registe
225
225
  export type { AuthGuardUser, ForgotPasswordViewProps, LoginFormProps, LoginFormValues, RegisterFormProps, RegisterFormValues, ResetPasswordViewProps, VerifyEmailViewProps, } from "./features/auth/index";
226
226
  export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister, useForgotPassword, useResetPassword, useVerifyEmail, useChangeEmail } from "./features/auth/index";
227
227
  export type { LoginCredentials, RegisterData, ForgotPasswordData, ResetPasswordData, VerifyEmailData, ChangeEmailData } from "./features/auth/index";
228
- export { CartView, CartItemRow, CartSummary, CartPriceBreakdown, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
228
+ export { CartView, CartItemRow, CartGroupLineRow, CartSummary, CartPriceBreakdown, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
229
229
  export { StoreAddonsPicker } from "./features/cart/index";
230
230
  export type { CartPriceBreakdownData, CartPriceBreakdownStore, StoreAddonsValue, StoreAddonsRates } from "./features/cart/index";
231
231
  export { getCartOps, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
232
232
  export type { CartOp } from "./features/cart/utils/pending-ops";
233
233
  export type { CartItem, CartItemMeta, CartData, GuestCartItem } from "./features/cart/index";
234
+ export type { CartLineKind, CartGroupSource, CartLineMember, } from "./features/cart/schemas/firestore";
235
+ export type { CartGroupLineRowProps } from "./features/cart/components/CartGroupLineRow";
234
236
  export { useAddresses, useCreateAddress, useUpdateAddress, useDeleteAddress, useSetDefaultAddress, useAddress } from "./features/account/index";
235
237
  export type { Address, AddressFormData } from "./features/account/index";
236
238
  export { AddressBook, AddressCard, AddressForm } from "./features/account/index";
@@ -262,6 +264,8 @@ export type { WishlistCapEventDetail } from "./features/wishlist/hooks/useWishli
262
264
  export { getGuestWishlistItems, addToGuestWishlist, removeFromGuestWishlist, isInGuestWishlist, clearGuestWishlist, getGuestWishlistCount, getGuestWishlistByType, } from "./features/wishlist/utils/guest-wishlist";
263
265
  export { InteractiveProductCard } from "./features/products/index";
264
266
  export type { InteractiveProductCardProps } from "./features/products/index";
267
+ export { GroupedListingDetailView } from "./features/grouped/components/GroupedListingDetailView";
268
+ export type { GroupedListingDetailViewProps } from "./features/grouped/components/GroupedListingDetailView";
265
269
  export { GroupMemberPicker } from "./features/products/components/GroupMemberPicker";
266
270
  export type { GroupMemberPickerProps, GroupPickerMember, } from "./features/products/components/GroupMemberPicker";
267
271
  export { MarketplaceBundleCard } from "./features/products/components/MarketplaceBundleCard";
@@ -420,8 +424,8 @@ export type { BecomeSellerResult, SellerCreateProductViewProps, SellerEditProduc
420
424
  export { BecomeSellerView } from "./features/account/index";
421
425
  export type { BidDocument } from "./features/auctions/index";
422
426
  export type { ClaimedCouponDocument } from "./features/promotions/schemas";
423
- export { groupOrderItemsByBundle } from "./features/orders/index";
424
- export type { BundleOrderGroup, OutOfStockPolicy } from "./features/orders/index";
427
+ export { groupOrderItemsByLine } from "./features/orders/index";
428
+ export type { LineOrderGroup, OutOfStockPolicy } from "./features/orders/index";
425
429
  export { MediaUploadField } from "./features/media/index";
426
430
  export { PrizeRevealModal } from "./features/products/components/PrizeRevealModal";
427
431
  export { useProduct } from "./features/products/index";
package/dist/client.js CHANGED
@@ -260,7 +260,7 @@ export { PAGE_VIEW_ENTITY_TYPES } from "./features/analytics/types";
260
260
  export { AuctionBidsTable } from "./features/auctions/components/AuctionBidsTable";
261
261
  export { ProtectedRoute, AuthStatusPanel, ForgotPasswordView, LoginForm, RegisterForm, ResetPasswordView, VerifyEmailView } from "./features/auth/index";
262
262
  export { useLogout, useLogin, useGoogleLogin, useLinkGoogleAccount, useRegister, useForgotPassword, useResetPassword, useVerifyEmail, useChangeEmail } from "./features/auth/index";
263
- export { CartView, CartItemRow, CartSummary, CartPriceBreakdown, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
263
+ export { CartView, CartItemRow, CartGroupLineRow, CartSummary, CartPriceBreakdown, CartDrawer, CheckoutView, CheckoutSuccessView, CheckoutAddressStep, useGuestCart, useCartCount, useAddToCart, useCart, useGuestCartMerge, useCartQuery } from "./features/cart/index";
264
264
  export { StoreAddonsPicker } from "./features/cart/index";
265
265
  export { getCartOps, CART_OPS_CHANGE_EVENT } from "./features/cart/utils/pending-ops";
266
266
  export { useAddresses, useCreateAddress, useUpdateAddress, useDeleteAddress, useSetDefaultAddress, useAddress } from "./features/account/index";
@@ -282,6 +282,7 @@ export { SyncManagerMount } from "./core/components/SyncManagerMount";
282
282
  export { useWishlistCount, useWishlistCountWithLimit, WISHLIST_CAP_EVENT } from "./features/wishlist/hooks/useWishlistCount";
283
283
  export { getGuestWishlistItems, addToGuestWishlist, removeFromGuestWishlist, isInGuestWishlist, clearGuestWishlist, getGuestWishlistCount, getGuestWishlistByType, } from "./features/wishlist/utils/guest-wishlist";
284
284
  export { InteractiveProductCard } from "./features/products/index";
285
+ export { GroupedListingDetailView } from "./features/grouped/components/GroupedListingDetailView";
285
286
  export { GroupMemberPicker } from "./features/products/components/GroupMemberPicker";
286
287
  export { MarketplaceBundleCard } from "./features/products/components/MarketplaceBundleCard";
287
288
  export { CompareOverlay } from "./features/products/components/CompareOverlay";
@@ -405,7 +406,7 @@ export { SpinWheelView, EventRaffleEntryForm } from "./features/events/index";
405
406
  export { Card, CardBody } from "./ui/index";
406
407
  export { SellerCouponsView, SellerCreateProductView, SellerEditProductView, useBecomeSeller, } from "./features/seller/index";
407
408
  export { BecomeSellerView } from "./features/account/index";
408
- export { groupOrderItemsByBundle } from "./features/orders/index";
409
+ export { groupOrderItemsByLine } from "./features/orders/index";
409
410
  export { MediaUploadField } from "./features/media/index";
410
411
  export { PrizeRevealModal } from "./features/products/components/PrizeRevealModal";
411
412
  export { useProduct } from "./features/products/index";
@@ -246,8 +246,6 @@ export declare const CART_ENDPOINTS: {
246
246
  * discriminated union would put this feature's blast radius on it.
247
247
  */
248
248
  readonly GROUP: "/api/cart/group";
249
- /** Replace a grouped line's member quantities (whole array, not a delta). */
250
- readonly GROUP_MEMBERS: (itemId: string) => string;
251
249
  };
252
250
  export declare const CATEGORY_ENDPOINTS: {
253
251
  readonly LIST: "/api/categories";
@@ -711,8 +709,6 @@ export declare const API_ENDPOINTS: {
711
709
  * discriminated union would put this feature's blast radius on it.
712
710
  */
713
711
  readonly GROUP: "/api/cart/group";
714
- /** Replace a grouped line's member quantities (whole array, not a delta). */
715
- readonly GROUP_MEMBERS: (itemId: string) => string;
716
712
  };
717
713
  readonly CATEGORIES: {
718
714
  readonly LIST: "/api/categories";
@@ -1178,8 +1174,6 @@ export declare const API_ROUTES: {
1178
1174
  * discriminated union would put this feature's blast radius on it.
1179
1175
  */
1180
1176
  readonly GROUP: "/api/cart/group";
1181
- /** Replace a grouped line's member quantities (whole array, not a delta). */
1182
- readonly GROUP_MEMBERS: (itemId: string) => string;
1183
1177
  };
1184
1178
  readonly CATEGORIES: {
1185
1179
  readonly LIST: "/api/categories";
@@ -286,8 +286,6 @@ export const CART_ENDPOINTS = {
286
286
  * discriminated union would put this feature's blast radius on it.
287
287
  */
288
288
  GROUP: "/api/cart/group",
289
- /** Replace a grouped line's member quantities (whole array, not a delta). */
290
- GROUP_MEMBERS: (itemId) => `/api/cart/${encodeURIComponent(itemId)}/members`,
291
289
  };
292
290
  // ---------------------------------------------------------------------------
293
291
  // Categories
@@ -2,5 +2,5 @@
2
2
  * page-views.repository.ts (server-only, imports firebase-admin) so the
3
3
  * client-side tracker component can import just this without pulling
4
4
  * firebase-admin into the client bundle. */
5
- export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle"];
5
+ export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle", "grouped-listing"];
6
6
  export type PageViewEntityType = (typeof PAGE_VIEW_ENTITY_TYPES)[number];
@@ -18,4 +18,7 @@ export const PAGE_VIEW_ENTITY_TYPES = [
18
18
  "digital-code",
19
19
  "live",
20
20
  "bundle",
21
+ /** A `groupedListings` document's own page. Distinct from "bundle" — a
22
+ * bundle is a priced, all-or-nothing set; a group is pick-as-you-wish. */
23
+ "grouped-listing",
21
24
  ];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * CartGroupLineRow — one cart line that stands for several products.
3
+ *
4
+ * Two shapes behind one component, because the difference is exactly the
5
+ * bundle-vs-group distinction and putting them in separate files would let it
6
+ * drift:
7
+ *
8
+ * BUNDLE — all-or-nothing. Members render read-only; the only stepper is the
9
+ * line's own and it means COPIES OF THE BUNDLE.
10
+ * GROUP — pick-as-you-wish. One stepper PER MEMBER, and no line-level
11
+ * stepper at all (a group line's `quantity` is pinned to 1, so a
12
+ * second multiplier would double-count).
13
+ *
14
+ * Never render both steppers. That is the invariant on `CartLineMember`
15
+ * expressed in UI.
16
+ */
17
+ import React from "react";
18
+ import type { CartLineMember } from "../schemas";
19
+ export interface CartGroupLineRowProps {
20
+ /** Line identity (`CartItemDocument.itemId`). */
21
+ id: string;
22
+ title: string;
23
+ image?: string;
24
+ currency?: string;
25
+ /** `"bundle"` → copies stepper; `"group"` → per-member steppers. */
26
+ lineKind: "bundle" | "group";
27
+ members: CartLineMember[];
28
+ /** Copies of the whole line. Bundles only — a group line is always 1. */
29
+ quantity: number;
30
+ /** Detail href for the bundle / group itself. */
31
+ href?: string;
32
+ locked?: boolean;
33
+ /** Bundle only: change the number of copies. */
34
+ onQtyChange?: (id: string, qty: number) => void;
35
+ /** Group only: change one member's per-copy quantity (0 removes it). */
36
+ onMemberQtyChange?: (id: string, productId: string, qty: number) => void;
37
+ onRemove?: (id: string) => void;
38
+ /** Per-member stock ceiling, keyed by productId. */
39
+ maxByProductId?: Record<string, number>;
40
+ variant?: "card" | "row";
41
+ }
42
+ export declare function CartGroupLineRow({ id, title, image, currency, lineKind, members, quantity, href, locked, onQtyChange, onMemberQtyChange, onRemove, maxByProductId, variant, }: CartGroupLineRowProps): React.JSX.Element;
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * CartGroupLineRow — one cart line that stands for several products.
5
+ *
6
+ * Two shapes behind one component, because the difference is exactly the
7
+ * bundle-vs-group distinction and putting them in separate files would let it
8
+ * drift:
9
+ *
10
+ * BUNDLE — all-or-nothing. Members render read-only; the only stepper is the
11
+ * line's own and it means COPIES OF THE BUNDLE.
12
+ * GROUP — pick-as-you-wish. One stepper PER MEMBER, and no line-level
13
+ * stepper at all (a group line's `quantity` is pinned to 1, so a
14
+ * second multiplier would double-count).
15
+ *
16
+ * Never render both steppers. That is the invariant on `CartLineMember`
17
+ * expressed in UI.
18
+ */
19
+ import { useState } from "react";
20
+ import { Button, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
21
+ import { QuantityStepper } from "../../../ui/components/QuantityStepper";
22
+ import { MediaImage } from "../../media/MediaImage";
23
+ import { formatCurrency } from "../../../utils/number.formatter";
24
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
25
+ const __O = { hidden: "overflow-hidden" };
26
+ export function CartGroupLineRow({ id, title, image, currency = "INR", lineKind, members, quantity, href, locked = false, onQtyChange, onMemberQtyChange, onRemove, maxByProductId, variant = "card", }) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const isRow = variant === "row";
29
+ const onePrice = members.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0);
30
+ const lineTotal = onePrice * quantity;
31
+ const unitCount = members.reduce((sum, m) => sum + m.quantity, 0);
32
+ return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: "min-w-0", children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: image && _jsx(MediaImage, { src: image, alt: title, size: "thumbnail" }) }), _jsxs(Stack, { justify: "between", gap: "xs", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", align: "start", className: "min-w-0", children: [href ? (_jsx(TextLink, { href: href, weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: title })), _jsx(Span, { padding: "pill-2xs", surface: "subtle", color: "muted", rounded: "default", className: "flex-shrink-0 text-[10px] tracking-wide", transform: "uppercase", children: lineKind === "bundle" ? "Bundle" : "Group" })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsOpen((v) => !v), "aria-expanded": isOpen, className: "self-start text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: [isOpen ? "▼" : "▶", " ", members.length, " item", members.length === 1 ? "" : "s", unitCount !== members.length ? ` · ${unitCount} units` : ""] }), isOpen && (_jsx(Stack, { gap: "xs", className: "w-full", children: members.map((m) => (_jsxs(Row, { gap: "sm", align: "center", justify: "between", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", className: "min-w-0 flex-1", children: [_jsx(Div, { className: `h-10 w-10 flex-shrink-0 ${__O.hidden}`, rounded: "default", surface: "muted", children: m.image && _jsx(MediaImage, { src: m.image, alt: m.title, size: "thumbnail" }) }), _jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsx(Text, { size: "sm", truncate: 1, color: "primary", children: m.title }), _jsxs(Text, { size: "xs", color: "muted", children: [formatCurrency(m.unitPrice, currency), lineKind === "bundle" ? ` × ${m.quantity}` : ""] })] })] }), lineKind === "group" && onMemberQtyChange && !locked && (_jsxs(Row, { gap: "xs", align: "center", className: "flex-shrink-0", children: [_jsx(QuantityStepper, { value: m.quantity, onChange: (next) => onMemberQtyChange(id, m.productId, next), min: 0, max: maxByProductId?.[m.productId], ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-group-member"].ariaLabel, onClick: () => onMemberQtyChange(id, m.productId, 0), className: "text-[var(--appkit-color-text-faint)] transition hover:text-error", children: "\u2715" })] })), lineKind === "bundle" && (_jsxs(Span, { size: "xs", color: "muted", className: "flex-shrink-0", children: ["\u00D7 ", m.quantity] }))] }, m.productId))) })), _jsxs(Row, { justify: "between", gap: "sm", className: "min-w-0", children: [_jsx(Text, { weight: "semibold", className: "text-[var(--appkit-color-text)]", children: formatCurrency(lineTotal, currency) }), lineKind === "bundle" && onQtyChange && !locked && (_jsx(QuantityStepper, { value: quantity, onChange: (next) => onQtyChange(id, next), min: 1, ariaLabel: `Number of copies of ${title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel }))] })] }), onRemove && !locked && (_jsx(Button, { onClick: () => onRemove(id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: "self-start text-[var(--appkit-color-text-faint)] transition hover:text-error", children: "\u2715" }))] }));
33
+ }
@@ -1,4 +1,6 @@
1
1
  export { CartItemRow, CartDrawer } from "./CartDrawer";
2
+ export { CartGroupLineRow } from "./CartGroupLineRow";
3
+ export type { CartGroupLineRowProps } from "./CartGroupLineRow";
2
4
  export { CartView } from "./CartView";
3
5
  export type { CartViewProps, CartOrderGroup } from "./CartView";
4
6
  export { CartSummary } from "./CartSummary";
@@ -1,4 +1,5 @@
1
1
  export { CartItemRow, CartDrawer } from "./CartDrawer";
2
+ export { CartGroupLineRow } from "./CartGroupLineRow";
2
3
  export { CartView } from "./CartView";
3
4
  export { CartSummary } from "./CartSummary";
4
5
  export { StoreAddonsPicker } from "./StoreAddonsPicker";
@@ -291,6 +291,12 @@ export class CartRepository extends BaseRepository {
291
291
  throw new NotFoundError(ERR_CART_ITEM_NOT_FOUND);
292
292
  if (cart.items[itemIndex].locked)
293
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
+ }
294
300
  const items = [...cart.items];
295
301
  items[itemIndex] = {
296
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;