@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
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Grouped-listing OG card.
3
+ *
4
+ * Deliberately delegates to `renderBundleOgImage` rather than re-implementing
5
+ * the layout: the card is the same shape (title, description, cover, item
6
+ * count) minus the price, and `BundleOgData.priceLabel` / `.stockStatus` are
7
+ * already nullable for exactly that case. Forking it would give two ~150-line
8
+ * OG layouts to keep visually in step — the Duplication Framework's
9
+ * "same prop surface, same output" consolidate rule.
10
+ */
11
+ import type { ReactElement } from "react";
12
+ import type { GroupedListingDocument } from "../../../../features/grouped/schemas/firestore";
13
+ export declare function renderGroupedListingOg(doc: Pick<GroupedListingDocument, "title" | "description" | "coverImage" | "productIds"> | null | undefined, opts: {
14
+ siteName: string;
15
+ baseUrl?: string;
16
+ }): ReactElement;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Grouped-listing OG card.
3
+ *
4
+ * Deliberately delegates to `renderBundleOgImage` rather than re-implementing
5
+ * the layout: the card is the same shape (title, description, cover, item
6
+ * count) minus the price, and `BundleOgData.priceLabel` / `.stockStatus` are
7
+ * already nullable for exactly that case. Forking it would give two ~150-line
8
+ * OG layouts to keep visually in step — the Duplication Framework's
9
+ * "same prop surface, same output" consolidate rule.
10
+ */
11
+ import { renderBundleOgImage } from "../bundles/og";
12
+ import { resolveOgImageUrl } from "../seo/og";
13
+ export function renderGroupedListingOg(doc, opts) {
14
+ return renderBundleOgImage({
15
+ name: doc?.title ?? "Group",
16
+ description: doc?.description?.slice(0, 140) ??
17
+ `A curated group of related listings on ${opts.siteName}.`,
18
+ coverImageUrl: resolveOgImageUrl(doc?.coverImage ?? null, opts.baseUrl),
19
+ // A group has no price of its own — the buyer's selection is the price.
20
+ priceLabel: null,
21
+ itemCount: doc?.productIds?.length ?? null,
22
+ stockStatus: null,
23
+ }, opts.siteName);
24
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Public projection for a product-group member.
3
+ *
4
+ * `GET /api/products/group/[groupId]` previously returned
5
+ * `sanitizeProductsForPublic(items)`, which is a DELETE-LIST of four
6
+ * seller-identity keys — every other field of `ProductDocument` went to the
7
+ * browser, including fields nobody had thought about. Per § "Public Data
8
+ * Projections", a public payload is built by naming what goes out, never by
9
+ * spreading a document and removing keys: a denial keyed on today's field list
10
+ * is structurally blind to the field added tomorrow.
11
+ *
12
+ * The member picker needs more than the old response carried (stock, store,
13
+ * sold state, gstRate), so widening the payload was required either way —
14
+ * which made this the moment to make it an allow-list rather than a wider
15
+ * deny-list.
16
+ */
17
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
18
+ import type { ListingType } from "../../../../features/products/types/index";
19
+ /**
20
+ * Every field the group picker and the group table render. If a field is not
21
+ * here it does not reach the browser.
22
+ *
23
+ * Deliberately absent, with reasons:
24
+ * - `sellerId` / `ownerId` / `sellerName` / `sellerEmail` — seller identity;
25
+ * public clients see store-scoped identity only.
26
+ * - `costPrice`, `profitMargin`, `supplier*`, `adminNotes` — seller-private
27
+ * commercial data.
28
+ * - `stockQuantity` — the internal figure; `availableQuantity` is the one the
29
+ * buyer may act on.
30
+ */
31
+ export interface PublicGroupMember {
32
+ id: string;
33
+ slug?: string;
34
+ title: string;
35
+ price: number;
36
+ currency?: string;
37
+ /** First image only — the picker renders a thumbnail, never a gallery. */
38
+ image?: string;
39
+ condition?: string;
40
+ listingType: ListingType;
41
+ isGroupParent?: boolean;
42
+ groupTitle?: string;
43
+ /** Drives the stepper's `max`. */
44
+ availableQuantity: number;
45
+ storeId: string;
46
+ storeName?: string;
47
+ status: string;
48
+ isSold?: boolean;
49
+ /** Needed so the picker can show a tax-inclusive hint and the cart line can
50
+ * snapshot it for per-member GST proration at checkout. */
51
+ gstRate?: 0 | 5 | 12 | 18 | 28;
52
+ hsnCode?: string;
53
+ }
54
+ export declare function toPublicGroupMember(doc: ProductDocument): PublicGroupMember;
55
+ export declare function toPublicGroupMembers(docs: readonly ProductDocument[]): PublicGroupMember[];
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Public projection for a product-group member.
3
+ *
4
+ * `GET /api/products/group/[groupId]` previously returned
5
+ * `sanitizeProductsForPublic(items)`, which is a DELETE-LIST of four
6
+ * seller-identity keys — every other field of `ProductDocument` went to the
7
+ * browser, including fields nobody had thought about. Per § "Public Data
8
+ * Projections", a public payload is built by naming what goes out, never by
9
+ * spreading a document and removing keys: a denial keyed on today's field list
10
+ * is structurally blind to the field added tomorrow.
11
+ *
12
+ * The member picker needs more than the old response carried (stock, store,
13
+ * sold state, gstRate), so widening the payload was required either way —
14
+ * which made this the moment to make it an allow-list rather than a wider
15
+ * deny-list.
16
+ */
17
+ import { normalizeListingType } from "../../../../features/products/utils/listing-type";
18
+ export function toPublicGroupMember(doc) {
19
+ return {
20
+ id: doc.id,
21
+ slug: doc.slug,
22
+ title: doc.title,
23
+ price: doc.price,
24
+ currency: doc.currency,
25
+ image: doc.images?.[0] ?? doc.mainImage,
26
+ condition: doc.condition,
27
+ listingType: normalizeListingType(doc),
28
+ isGroupParent: doc.isGroupParent,
29
+ groupTitle: doc.groupTitle,
30
+ availableQuantity: doc.availableQuantity ?? 0,
31
+ storeId: doc.storeId,
32
+ storeName: doc.storeName,
33
+ status: doc.status,
34
+ isSold: doc.isSold,
35
+ gstRate: doc.gstRate,
36
+ hsnCode: doc.hsnCode,
37
+ };
38
+ }
39
+ export function toPublicGroupMembers(docs) {
40
+ return docs.map(toPublicGroupMember);
41
+ }
@@ -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,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * "What products does this cart line actually consume?" — the one answer.
3
+ *
4
+ * Lives in `shared/` rather than `server/features/checkout/` because three
5
+ * different layers need it and two of them are not server code:
6
+ * - `order-math.ts` (pricing + tax, shared)
7
+ * - `rules/_registry.ts` (preflight skip, shared)
8
+ * - `bundle-expansion.ts` (stock fan-out, server)
9
+ * - `stock-restore.ts` (restore fan-out, server)
10
+ *
11
+ * Pure functions, no Firestore, no framework imports.
12
+ */
13
+ import type { CartItemDocument } from "../../../features/cart/schemas/firestore";
14
+ /** One member of a cart line, with the units required per COPY of that line. */
15
+ export interface CartLineMemberRef {
16
+ productId: string;
17
+ quantity: number;
18
+ }
19
+ /**
20
+ * Resolve a line's members. Three sources, in priority order — never read
21
+ * `bundleProductIds` directly at a call site:
22
+ *
23
+ * 1. `groupMembers` — authoritative, and the only shape that can express
24
+ * per-member quantities.
25
+ * 2. `bundleProductIds` — legacy bundle lines written before `groupMembers`
26
+ * existed, resolved at quantity 1 each. This fallback is what makes carts
27
+ * that were already in flight behave byte-identically, with no migration.
28
+ * 3. the line's own `productId` — an ordinary single-product line.
29
+ *
30
+ * Keeping this a derivation rather than storing a second array is deliberate: a
31
+ * hand-maintained mirror drifts the first time one write path forgets it
32
+ * (Recurrent Root Cause #42).
33
+ */
34
+ export declare function getCartLineMembers(item: CartItemDocument): CartLineMemberRef[];
35
+ /**
36
+ * True when the line stands for several products rather than one.
37
+ *
38
+ * Replaces the scattered `item.bundleProductIds?.length` tests, which each
39
+ * meant "is this a bundle" and would all have silently answered "no" for a
40
+ * `groupMembers`-backed line.
41
+ */
42
+ export declare function isMultiMemberLine(item: CartItemDocument): boolean;
43
+ /** Just the product ids — see `getCartLineMembers` for the resolution order. */
44
+ export declare function getCartItemMemberIds(item: CartItemDocument): string[];
45
+ /** The order-side shape this module needs. Structural on purpose — it matches
46
+ * `OrderDocumentItem` without dragging the orders schema into shared checkout. */
47
+ export interface OrderItemMemberSource {
48
+ productId: string;
49
+ bundleProductIds?: string[];
50
+ }
51
+ /**
52
+ * The order-side twin of `getCartLineMembers`, used by stock restore.
53
+ *
54
+ * A LEGACY order row is collapsed: one row standing for a whole bundle, with
55
+ * its members listed in `bundleProductIds` at one unit each. Rows written after
56
+ * grouped lines were expanded carry no `bundleProductIds` at all — each row is
57
+ * already exactly one product — so they fall through to the single-member
58
+ * branch and need no special case.
59
+ */
60
+ export declare function getOrderItemMemberRefs(item: OrderItemMemberSource): CartLineMemberRef[];
@@ -0,0 +1,68 @@
1
+ /**
2
+ * "What products does this cart line actually consume?" — the one answer.
3
+ *
4
+ * Lives in `shared/` rather than `server/features/checkout/` because three
5
+ * different layers need it and two of them are not server code:
6
+ * - `order-math.ts` (pricing + tax, shared)
7
+ * - `rules/_registry.ts` (preflight skip, shared)
8
+ * - `bundle-expansion.ts` (stock fan-out, server)
9
+ * - `stock-restore.ts` (restore fan-out, server)
10
+ *
11
+ * Pure functions, no Firestore, no framework imports.
12
+ */
13
+ /**
14
+ * Resolve a line's members. Three sources, in priority order — never read
15
+ * `bundleProductIds` directly at a call site:
16
+ *
17
+ * 1. `groupMembers` — authoritative, and the only shape that can express
18
+ * per-member quantities.
19
+ * 2. `bundleProductIds` — legacy bundle lines written before `groupMembers`
20
+ * existed, resolved at quantity 1 each. This fallback is what makes carts
21
+ * that were already in flight behave byte-identically, with no migration.
22
+ * 3. the line's own `productId` — an ordinary single-product line.
23
+ *
24
+ * Keeping this a derivation rather than storing a second array is deliberate: a
25
+ * hand-maintained mirror drifts the first time one write path forgets it
26
+ * (Recurrent Root Cause #42).
27
+ */
28
+ export function getCartLineMembers(item) {
29
+ if (item.groupMembers?.length) {
30
+ return item.groupMembers.map((m) => ({
31
+ productId: m.productId,
32
+ quantity: m.quantity,
33
+ }));
34
+ }
35
+ if (item.bundleProductIds?.length) {
36
+ return item.bundleProductIds.map((productId) => ({ productId, quantity: 1 }));
37
+ }
38
+ return [{ productId: item.productId, quantity: 1 }];
39
+ }
40
+ /**
41
+ * True when the line stands for several products rather than one.
42
+ *
43
+ * Replaces the scattered `item.bundleProductIds?.length` tests, which each
44
+ * meant "is this a bundle" and would all have silently answered "no" for a
45
+ * `groupMembers`-backed line.
46
+ */
47
+ export function isMultiMemberLine(item) {
48
+ return Boolean(item.groupMembers?.length || item.bundleProductIds?.length);
49
+ }
50
+ /** Just the product ids — see `getCartLineMembers` for the resolution order. */
51
+ export function getCartItemMemberIds(item) {
52
+ return getCartLineMembers(item).map((m) => m.productId);
53
+ }
54
+ /**
55
+ * The order-side twin of `getCartLineMembers`, used by stock restore.
56
+ *
57
+ * A LEGACY order row is collapsed: one row standing for a whole bundle, with
58
+ * its members listed in `bundleProductIds` at one unit each. Rows written after
59
+ * grouped lines were expanded carry no `bundleProductIds` at all — each row is
60
+ * already exactly one product — so they fall through to the single-member
61
+ * branch and need no special case.
62
+ */
63
+ export function getOrderItemMemberRefs(item) {
64
+ if (item.bundleProductIds?.length) {
65
+ return item.bundleProductIds.map((productId) => ({ productId, quantity: 1 }));
66
+ }
67
+ return [{ productId: item.productId, quantity: 1 }];
68
+ }
@@ -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
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * No grep of the consumer codebase needed.
10
10
  */
11
+ import { isMultiMemberLine } from "../line-members";
11
12
  import { standardRule } from "./standard.rule";
12
13
  import { auctionRule } from "./auction.rule";
13
14
  import { preOrderRule } from "./preorder.rule";
@@ -77,8 +78,13 @@ export function getSplitKey(item) {
77
78
  export function runSyncPreflight(pairs, paymentMethod) {
78
79
  const byType = new Map();
79
80
  for (const pair of pairs) {
80
- if (pair.item.bundleProductIds?.length)
81
- continue; // bundles skip — direct checkout
81
+ // Multi-member lines (bundle or grouped selection) skip the per-listing-type
82
+ // preflight: their `listingType` describes the wrapper, not the members, so
83
+ // a per-type rule would be applied to the wrong thing. Tested via the shared
84
+ // predicate rather than `bundleProductIds?.length`, which answers "is this a
85
+ // legacy bundle" and would say no for a `groupMembers`-backed line.
86
+ if (isMultiMemberLine(pair.item))
87
+ continue;
82
88
  const lt = (pair.item.listingType ?? "standard");
83
89
  const bucket = byType.get(lt) ?? [];
84
90
  bucket.push(pair);
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,10 @@ 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";
269
+ export { GroupMemberPicker } from "./features/products/components/GroupMemberPicker";
270
+ export type { GroupMemberPickerProps, GroupPickerMember, } from "./features/products/components/GroupMemberPicker";
265
271
  export { MarketplaceBundleCard } from "./features/products/components/MarketplaceBundleCard";
266
272
  export type { MarketplaceBundleCardProps, MarketplaceBundleCardData, MarketplaceBundleCardLabels, } from "./features/products/components/MarketplaceBundleCard";
267
273
  export { CompareOverlay } from "./features/products/components/CompareOverlay";
@@ -418,8 +424,8 @@ export type { BecomeSellerResult, SellerCreateProductViewProps, SellerEditProduc
418
424
  export { BecomeSellerView } from "./features/account/index";
419
425
  export type { BidDocument } from "./features/auctions/index";
420
426
  export type { ClaimedCouponDocument } from "./features/promotions/schemas";
421
- export { groupOrderItemsByBundle } from "./features/orders/index";
422
- export type { BundleOrderGroup, OutOfStockPolicy } from "./features/orders/index";
427
+ export { groupOrderItemsByLine } from "./features/orders/index";
428
+ export type { LineOrderGroup, OutOfStockPolicy } from "./features/orders/index";
423
429
  export { MediaUploadField } from "./features/media/index";
424
430
  export { PrizeRevealModal } from "./features/products/components/PrizeRevealModal";
425
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,8 @@ 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";
286
+ export { GroupMemberPicker } from "./features/products/components/GroupMemberPicker";
285
287
  export { MarketplaceBundleCard } from "./features/products/components/MarketplaceBundleCard";
286
288
  export { CompareOverlay } from "./features/products/components/CompareOverlay";
287
289
  export { COMPARE_MAX_ITEMS } from "./features/products/constants/action-defs";
@@ -404,7 +406,7 @@ export { SpinWheelView, EventRaffleEntryForm } from "./features/events/index";
404
406
  export { Card, CardBody } from "./ui/index";
405
407
  export { SellerCouponsView, SellerCreateProductView, SellerEditProductView, useBecomeSeller, } from "./features/seller/index";
406
408
  export { BecomeSellerView } from "./features/account/index";
407
- export { groupOrderItemsByBundle } from "./features/orders/index";
409
+ export { groupOrderItemsByLine } from "./features/orders/index";
408
410
  export { MediaUploadField } from "./features/media/index";
409
411
  export { PrizeRevealModal } from "./features/products/components/PrizeRevealModal";
410
412
  export { useProduct } from "./features/products/index";