@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
@@ -7,47 +7,57 @@
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
  /**
23
- * Walk the items array preserving original order; collapse contiguous (or
24
- * non-contiguous) bundle rows sharing the same `bundleCategorySlug` into a
25
- * single group. Single rows pass through unchanged.
24
+ * Walk the items array preserving original order; collapse rows sharing a
25
+ * collapse key into one group. Single rows pass through unchanged.
26
+ *
27
+ * The key is `groupSlug ?? bundleCategorySlug` — the fallback is what keeps
28
+ * legacy collapsed bundle rows (written before `groupSlug` existed) grouping
29
+ * exactly as they always did, with no order migration.
26
30
  */
27
- export function groupOrderItemsByBundle(items) {
31
+ export function groupOrderItemsByLine(items) {
28
32
  const groups = [];
29
- const bundleIndexBySlug = new Map();
33
+ const indexByKey = new Map();
30
34
  items.forEach((item, index) => {
31
- const slug = item.bundleCategorySlug;
32
- if (!slug) {
35
+ const key = item.groupSlug ?? item.bundleCategorySlug;
36
+ if (!key) {
33
37
  groups.push({ kind: "single", item, index });
34
38
  return;
35
39
  }
36
- const existingIdx = bundleIndexBySlug.get(slug);
40
+ const existingIdx = indexByKey.get(key);
37
41
  if (existingIdx === undefined) {
38
- const memberCount = item.bundleProductIds?.length ?? 1;
39
42
  groups.push({
40
43
  kind: "bundle",
41
- bundleCategorySlug: slug,
44
+ bundleCategorySlug: key,
45
+ groupTitle: item.groupTitle,
46
+ // Legacy rows carry the member list; expanded rows ARE the members, so
47
+ // the count is grown as siblings arrive below.
48
+ memberCount: item.bundleProductIds?.length ?? 1,
42
49
  items: [{ item, index }],
43
- memberCount,
44
50
  });
45
- bundleIndexBySlug.set(slug, groups.length - 1);
51
+ indexByKey.set(key, groups.length - 1);
46
52
  return;
47
53
  }
48
54
  const existing = groups[existingIdx];
49
55
  if (existing.kind === "bundle") {
50
56
  existing.items.push({ item, index });
57
+ // Expanded rows: one row per member, so the row count IS the member count.
58
+ if (!item.bundleProductIds?.length) {
59
+ existing.memberCount = existing.items.length;
60
+ }
51
61
  }
52
62
  });
53
63
  return groups;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * GroupMemberPicker — pick which members of a group you want, and how many of
3
+ * each, then add the whole selection as ONE cart line.
4
+ *
5
+ * This is the "pick as you wish" half of the grouped-purchase model. Bundles
6
+ * are the other half and deliberately do NOT use this: a bundle is priced as a
7
+ * whole and is all-or-nothing, so its members render read-only and the only
8
+ * stepper is the bundle's own copy count.
9
+ *
10
+ * Two mount points, one component: the "Part of: …" modal/drawer on a product
11
+ * detail page, and the public `/groups/{slug}` page.
12
+ *
13
+ * The component talks to the API directly rather than taking an `onAdd` server
14
+ * action prop. That keeps `ShowGroupSection`'s `renderGroupSection` slot
15
+ * signature unchanged, which in turn leaves both detail-page views and both
16
+ * consumer pages untouched.
17
+ */
18
+ import React from "react";
19
+ import type { ListingType } from "../types";
20
+ export interface GroupPickerMember {
21
+ id: string;
22
+ title: string;
23
+ price: number;
24
+ currency?: string;
25
+ images?: string[];
26
+ image?: string;
27
+ slug?: string;
28
+ listingType?: ListingType;
29
+ isGroupParent?: boolean;
30
+ condition?: string;
31
+ availableQuantity?: number;
32
+ storeId?: string;
33
+ storeName?: string;
34
+ isSold?: boolean;
35
+ status?: string;
36
+ }
37
+ export interface GroupMemberPickerProps {
38
+ groupId: string;
39
+ groupSource: "product-group" | "grouped-listing";
40
+ members: GroupPickerMember[];
41
+ /** Called after a successful add so the host can close its overlay. */
42
+ onAdded?: () => void;
43
+ /** Called when the API reports the caller isn't signed in. */
44
+ onAuthRequired?: () => void;
45
+ }
46
+ export declare function GroupMemberPicker({ groupId, groupSource, members, onAdded, onAuthRequired, }: GroupMemberPickerProps): React.JSX.Element;
@@ -0,0 +1,108 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * GroupMemberPicker — pick which members of a group you want, and how many of
5
+ * each, then add the whole selection as ONE cart line.
6
+ *
7
+ * This is the "pick as you wish" half of the grouped-purchase model. Bundles
8
+ * are the other half and deliberately do NOT use this: a bundle is priced as a
9
+ * whole and is all-or-nothing, so its members render read-only and the only
10
+ * stepper is the bundle's own copy count.
11
+ *
12
+ * Two mount points, one component: the "Part of: …" modal/drawer on a product
13
+ * detail page, and the public `/groups/{slug}` page.
14
+ *
15
+ * The component talks to the API directly rather than taking an `onAdd` server
16
+ * action prop. That keeps `ShowGroupSection`'s `renderGroupSection` slot
17
+ * signature unchanged, which in turn leaves both detail-page views and both
18
+ * consumer pages untouched.
19
+ */
20
+ import { useMemo, useState } from "react";
21
+ import { Alert, Button, Div, Row, Span, Stack, Table, Tbody, Td, Text, Th, Thead, Tr, } from "../../../ui";
22
+ import { QuantityStepper } from "../../../ui/components/QuantityStepper";
23
+ import { MediaImage } from "../../media/MediaImage";
24
+ import { formatCurrency } from "../../../utils/number.formatter";
25
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
26
+ import { CART_ENDPOINTS } from "../../../constants/api-endpoints";
27
+ import { normalizeError } from "../../../errors/normalize";
28
+ import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
29
+ import { normalizeListingType } from "../utils/listing-type";
30
+ const __O = { xAuto: "overflow-x-auto", hidden: "overflow-hidden" };
31
+ function memberImage(m) {
32
+ return m.image ?? m.images?.[0] ?? "";
33
+ }
34
+ function memberHref(m) {
35
+ return pluginFor(normalizeListingType(m)).detailRoute(m.slug ?? m.id);
36
+ }
37
+ /** A member the buyer cannot choose, and the reason to show instead of a stepper. */
38
+ function blockedReason(m) {
39
+ if (m.isSold)
40
+ return "Sold";
41
+ if (m.status && m.status !== "published")
42
+ return "Unavailable";
43
+ if ((m.availableQuantity ?? 0) <= 0)
44
+ return "Out of stock";
45
+ // `cartLine: "blocked"` is how a classified / live listing says "contact the
46
+ // seller instead" — those can never join a cart line.
47
+ if (pluginFor(normalizeListingType(m)).cartLine === "blocked")
48
+ return "Not purchasable here";
49
+ return null;
50
+ }
51
+ export function GroupMemberPicker({ groupId, groupSource, members, onAdded, onAuthRequired, }) {
52
+ const [qty, setQty] = useState({});
53
+ const [isSaving, setIsSaving] = useState(false);
54
+ const [error, setError] = useState(null);
55
+ // A group whose members span sellers cannot become one cart line: storeId is
56
+ // the order-splitting key and the key per-store add-ons, coupons, shipping and
57
+ // payout all hang off. Rather than offer a button that always fails, the
58
+ // picker stands down and the table stays read-only.
59
+ const storeIds = useMemo(() => new Set(members.map((m) => m.storeId).filter(Boolean)), [members]);
60
+ const isCrossStore = storeIds.size > 1;
61
+ const selected = useMemo(() => members
62
+ .map((m) => ({ member: m, quantity: qty[m.id] ?? 0 }))
63
+ .filter((s) => s.quantity > 0), [members, qty]);
64
+ const selectedTotal = selected.reduce((sum, s) => sum + s.member.price * s.quantity, 0);
65
+ const selectedUnits = selected.reduce((sum, s) => sum + s.quantity, 0);
66
+ const currency = members[0]?.currency ?? "INR";
67
+ const handleAdd = async () => {
68
+ setError(null);
69
+ setIsSaving(true);
70
+ try {
71
+ const res = await fetch(CART_ENDPOINTS.GROUP, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({
75
+ groupId,
76
+ groupSource,
77
+ // Ids and quantities only. Prices and titles are re-resolved on the
78
+ // server — anything else would be a price-manipulation hole.
79
+ members: selected.map((s) => ({ productId: s.member.id, quantity: s.quantity })),
80
+ }),
81
+ });
82
+ if (res.status === 401) {
83
+ onAuthRequired?.();
84
+ return;
85
+ }
86
+ const json = (await res.json());
87
+ if (!res.ok || json.success === false) {
88
+ setError(json.error ?? "Couldn't add those items to your cart.");
89
+ return;
90
+ }
91
+ setQty({});
92
+ onAdded?.();
93
+ }
94
+ catch (err) {
95
+ void normalizeError(err);
96
+ setError("Couldn't reach the server. Please try again.");
97
+ }
98
+ finally {
99
+ setIsSaving(false);
100
+ }
101
+ };
102
+ return (_jsxs(Stack, { gap: "sm", children: [isCrossStore && (_jsx(Alert, { variant: "info", children: "These items are sold by different sellers, so they can't be combined into one cart line. Open an item to buy it on its own." })), _jsx(Div, { className: __O.xAuto, children: _jsxs(Table, { className: "text-left min-w-[440px]", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Image" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Name" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Price" }), !isCrossStore && (_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Qty" })), _jsx(Th, { paddingY: "sm", paddingSide: "pb-sm", color: "muted", size: "xs", weight: "semibold" })] }) }), _jsx(Tbody, { children: members.map((m) => {
103
+ const blocked = blockedReason(m);
104
+ return (_jsxs(Tr, { className: "last:border-0", border: "subtle", children: [_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Div, { className: `w-10 h-10 ${__O.hidden}`, rounded: "full", border: "default", children: _jsx(MediaImage, { src: memberImage(m), alt: m.title, size: "thumbnail" }) }) }), _jsxs(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: [_jsx(Text, { className: "line-clamp-2", color: "primary", size: "sm", weight: "medium", children: m.title }), m.isGroupParent && (_jsx(Span, { weight: "semibold", className: "text-[10px] text-[var(--appkit-color-primary)]", children: "Parent" }))] }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "sm", color: "muted", children: formatCurrency(m.price, m.currency ?? currency) }) }), !isCrossStore && (_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: blocked ? (_jsx(Span, { size: "xs", color: "muted", surface: "muted", rounded: "default", padding: "pill-2xs", children: blocked })) : (_jsx(QuantityStepper, { value: qty[m.id] ?? 0, onChange: (next) => setQty((prev) => ({ ...prev, [m.id]: next })), min: 0, max: m.availableQuantity, ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })) })), _jsx(Td, { padding: "xs-tall", children: _jsx("a", { href: memberHref(m), className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: "View \u2192" }) })] }, m.id));
105
+ }) })] }) }), !isCrossStore && (_jsxs(_Fragment, { children: [error && _jsx(Alert, { variant: "error", children: error }), _jsxs(Row, { justify: "between", align: "center", gap: "sm", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: "muted", children: selectedUnits > 0
106
+ ? `${selectedUnits} item${selectedUnits === 1 ? "" : "s"} · ${formatCurrency(selectedTotal, currency)}`
107
+ : "Nothing selected yet" }), _jsx(Button, { action: ACTIONS.CART["add-group-to-cart"], disabled: selectedUnits === 0, isLoading: isSaving, onClick: handleAdd })] })] }))] }));
108
+ }
@@ -2,8 +2,9 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from "react";
4
4
  import Link from "next/link";
5
- import { Div, Row, Span, Table, Thead, Tbody, Tr, Th, Td, Text, Modal, SideDrawer, Button } from "../../../ui";
5
+ import { Div, Row, Span, Text, Modal, SideDrawer, Button } from "../../../ui";
6
6
  import { MediaImage } from "../../media/MediaImage";
7
+ import { GroupMemberPicker } from "./GroupMemberPicker";
7
8
  import { formatCurrency } from "../../../utils/number.formatter";
8
9
  import { normalizeListingType } from "../utils/listing-type";
9
10
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
@@ -24,12 +25,6 @@ function MemberThumb({ member, isCurrent }) {
24
25
  ? "border-[var(--appkit-color-primary)] ring-2 ring-[var(--appkit-color-primary)]/30"
25
26
  : "border-[var(--appkit-color-border)] group-hover:border-[var(--appkit-color-primary)]"}`, children: [_jsx(MediaImage, { src: image, alt: member.title, size: "card" }), member.isGroupParent && (_jsx(Span, { color: "inverse", rounded: "sm", className: "absolute bottom-0 right-0 bg-[var(--appkit-color-primary)] text-[8px] leading-none px-[0.25rem] py-[0.125rem]", children: "Set" }))] }), _jsx(Text, { className: "text-[10px] leading-tight line-clamp-2 w-full", color: "muted", align: "start", children: member.title }), _jsx(Text, { className: "text-[10px]", color: "primary", weight: "semibold", children: price })] }));
26
27
  }
27
- function GroupTableRow({ member }) {
28
- const href = memberHref(member);
29
- const price = formatCurrency(member.price, member.currency ?? "INR");
30
- const image = member.images?.[0] ?? "";
31
- return (_jsxs(Tr, { className: "last:border-0", border: "subtle", children: [_jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Div, { className: `w-10 h-10 ${__O.hidden}`, rounded: "full", border: "default", children: _jsx(MediaImage, { src: image, alt: member.title, size: "thumbnail" }) }) }), _jsxs(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: [_jsx(Text, { className: "line-clamp-2", color: "primary", size: "sm", weight: "medium", children: member.title }), member.isGroupParent && (_jsx(Span, { weight: "semibold", className: "text-[10px] text-[var(--appkit-color-primary)]", children: "Parent" }))] }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "sm", color: "muted", children: price }) }), _jsx(Td, { paddingSide: "pr-sm", padding: "xs-tall", children: _jsx(Text, { size: "xs", transform: "capitalize", color: "muted", children: member.condition ?? "—" }) }), _jsx(Td, { padding: "xs-tall", children: _jsx(Link, { href: href, className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: "View \u2192" }) })] }));
32
- }
33
28
  export function ShowGroupSection({ groupId, currentSlug, isParent, groupTitle }) {
34
29
  const [open, setOpen] = useState(false);
35
30
  const [members, setMembers] = useState([]);
@@ -50,11 +45,14 @@ export function ShowGroupSection({ groupId, currentSlug, isParent, groupTitle })
50
45
  const label = groupTitle ?? "Product group";
51
46
  const parentLabel = isParent ? `Parts in this group: ${label}` : `Part of: ${label}`;
52
47
  const useDrawer = members.length >= 5;
53
- const tableContent = (_jsx(Div, { className: `${__O.xAuto}`, children: _jsxs(Table, { className: "text-left min-w-[400px]", children: [_jsx(Thead, { children: _jsxs(Tr, { border: "default", children: [_jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Image" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Name" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Price" }), _jsx(Th, { paddingY: "sm", paddingSide: "pr-sm", color: "muted", size: "xs", weight: "semibold", children: "Condition" }), _jsx(Th, { paddingY: "sm", paddingSide: "pb-sm", color: "muted", size: "xs", weight: "semibold" })] }) }), _jsx(Tbody, { children: members.map((m) => _jsx(GroupTableRow, { member: m }, m.id)) })] }) }));
48
+ // The overlay is now a PICKER, not a read-only table: pick how many of each
49
+ // member you want and add the whole selection as one cart line. The picker
50
+ // stands itself down (read-only, no CTA) when the group spans sellers.
51
+ const tableContent = (_jsx(GroupMemberPicker, { groupId: groupId, groupSource: "product-group", members: members, onAdded: () => setShowAll(false), onAuthRequired: () => setShowAll(false) }));
54
52
  return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `${__O.hidden}`, surface: "muted", rounded: "xl", border: "default", children: [_jsxs(Row, { role: "button", tabIndex: 0, onClick: () => setOpen((v) => !v), onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") {
55
53
  e.preventDefault();
56
54
  setOpen((v) => !v);
57
- } }, justify: "between", paddingX: "x-md", paddingY: "y-sm", className: "w-full cursor-pointer text-left hover:bg-[var(--appkit-color-surface-elevated)]/70 transition-colors", "aria-expanded": open, children: [_jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Span, { size: "xs", className: "mr-1", color: "faint", children: open ? "▼" : "▶" }), _jsx(Text, { size: "sm", weight: "medium", color: "primary", children: parentLabel }), _jsx(Span, { size: "xs", className: "ml-1", rounded: "full", padding: "pill-xs", surface: "subtle", color: "muted", children: members.length })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); setShowAll(true); }, className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline ml-3 flex-shrink-0", children: "View whole group \u2192" })] }), open && (_jsx(Div, { className: `pb-[1rem] pt-[0.25rem] ${__O.xAuto}`, padding: "x-md", children: _jsx(Row, { gap: "3", className: "min-w-0", children: [...members]
55
+ } }, justify: "between", paddingX: "x-md", paddingY: "y-sm", className: "w-full cursor-pointer text-left hover:bg-[var(--appkit-color-surface-elevated)]/70 transition-colors", "aria-expanded": open, children: [_jsxs(Row, { align: "center", gap: "xs", children: [_jsx(Span, { size: "xs", className: "mr-1", color: "faint", children: open ? "▼" : "▶" }), _jsx(Text, { size: "sm", weight: "medium", color: "primary", children: parentLabel }), _jsx(Span, { size: "xs", className: "ml-1", rounded: "full", padding: "pill-xs", surface: "subtle", color: "muted", children: members.length })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: (e) => { e.stopPropagation(); setShowAll(true); }, className: "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline ml-3 flex-shrink-0", children: "Pick items \u2192" })] }), open && (_jsx(Div, { className: `pb-[1rem] pt-[0.25rem] ${__O.xAuto}`, padding: "x-md", children: _jsx(Row, { gap: "3", className: "min-w-0", children: [...members]
58
56
  .sort((a, b) => (b.isGroupParent ? 1 : 0) - (a.isGroupParent ? 1 : 0))
59
57
  .map((m) => (_jsx(MemberThumb, { member: m, isCurrent: m.slug === currentSlug || m.id === currentSlug }, m.id))) }) }))] }), useDrawer ? (_jsx(SideDrawer, { isOpen: showAll, onClose: () => setShowAll(false), title: label, children: tableContent })) : (_jsx(Modal, { open: showAll, onClose: () => setShowAll(false), title: label, children: tableContent }))] }));
60
58
  }
@@ -188,6 +188,12 @@ export const categoriesTesterSeedData = [
188
188
  bundleProductIds: ["product-tester-standard-1", "product-tester-standard-2"],
189
189
  bundleOriginalTotal: 348, // 199 + 149
190
190
  bundleStockStatus: "in_stock",
191
+ // Without these, `addBundleToCartAction` wrote `storeId: ""` on the cart
192
+ // line, which produced an order group keyed `standard:` with an undefined
193
+ // storeId — no seller notification, no shipping resolution, no payout. The
194
+ // bug was seeded in, so it reproduced on every tester run.
195
+ createdByStoreId: "store-tester-qa-seller",
196
+ createdByStoreName: "Tester Sandbox Store",
191
197
  rootId: "bundle-tester-sandbox",
192
198
  parentIds: [],
193
199
  childrenIds: [],
@@ -232,6 +232,9 @@ export const productsTesterSeedData = [
232
232
  customSections: [],
233
233
  isPromoted: false,
234
234
  isOnSale: false,
235
+ // 18% — contrasts with member #3's 12% so one grouped line spans two rates.
236
+ gstRate: 18,
237
+ hsnCode: "95030090",
235
238
  // Child of the "group-tester-sandbox-bundle" product-group ("Set") parent
236
239
  // below — findByGroupId() queries products by this field directly, so
237
240
  // without it the parent's groupChildSlugs[] mirror is never actually
@@ -265,6 +268,40 @@ export const productsTesterSeedData = [
265
268
  customSections: [],
266
269
  isPromoted: false,
267
270
  isOnSale: false,
271
+ gstRate: 18,
272
+ hsnCode: "95030090",
273
+ groupId: "group-tester-sandbox-bundle",
274
+ groupParentSlug: "group-tester-sandbox-bundle",
275
+ groupTitle: TESTER_GROUP_TITLE,
276
+ createdAt: new Date(),
277
+ updatedAt: new Date(),
278
+ }),
279
+ withTokens({
280
+ id: "product-tester-standard-3",
281
+ slug: "product-tester-standard-3",
282
+ title: "Test Sticker Pack — Standard Listing #3",
283
+ description: "Third member of the test product group. Carries a DIFFERENT gstRate from its siblings on purpose: it is the only fixture that exercises per-member GST proration on a grouped/bundled cart line, where taxing the whole line at one product's rate would be wrong in both directions. Auto-expires in 7 days.",
284
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
285
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
286
+ brandSlug: "brand-tester-sandbox",
287
+ brand: "TestBrand",
288
+ price: 99,
289
+ currency: "INR",
290
+ stockQuantity: 10,
291
+ availableQuantity: 10,
292
+ isSold: false,
293
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-standard-3-20260101/900/900"),
294
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-standard-3-20260101/900/900")],
295
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
296
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
297
+ listingType: "standard",
298
+ customFields: [],
299
+ customSections: [],
300
+ isPromoted: false,
301
+ isOnSale: false,
302
+ // 12% against the siblings' 18% — the whole point of this fixture.
303
+ gstRate: 12,
304
+ hsnCode: "49081000",
268
305
  groupId: "group-tester-sandbox-bundle",
269
306
  groupParentSlug: "group-tester-sandbox-bundle",
270
307
  groupTitle: TESTER_GROUP_TITLE,
@@ -644,7 +681,13 @@ export const productsTesterSeedData = [
644
681
  isGroupParent: true,
645
682
  groupId: "group-tester-sandbox-bundle",
646
683
  groupTitle: TESTER_GROUP_TITLE,
647
- groupChildSlugs: ["product-tester-standard-1", "product-tester-standard-2"],
684
+ // Parent↔child mirror: every id here must also carry `groupId` on its own
685
+ // document. The pair drifting is Root Cause #42's second instance.
686
+ groupChildSlugs: [
687
+ "product-tester-standard-1",
688
+ "product-tester-standard-2",
689
+ "product-tester-standard-3",
690
+ ],
648
691
  createdAt: new Date(),
649
692
  updatedAt: new Date(),
650
693
  }),
@@ -729,6 +729,39 @@ const rawTesterChecklistItems = [
729
729
  href: "/cart",
730
730
  cases: [
731
731
  { key: "update-qty", label: "Updating item quantity in cart recalculates the total" },
732
+ {
733
+ key: "group-picker-add-selected",
734
+ label: "On a product that is part of a group, \"Pick items →\" opens a picker with +/- per member, and \"Add selected to cart\" creates ONE cart line (not one per item)",
735
+ description: "Use the Test Gadget page. Set two different members to different quantities, check the running total in the picker footer, then add. The cart should show a single line, not three.",
736
+ href: "/products/product-tester-standard-1",
737
+ },
738
+ {
739
+ key: "group-line-editable-in-cart",
740
+ label: "A grouped cart line expands to show its members, each with its own +/- and remove; removing the last member removes the whole line",
741
+ description: "There should be NO line-level quantity stepper on a grouped line — the per-member steppers carry the whole selection. Changing one member should update both the line total and the seller subtotal.",
742
+ href: "/cart",
743
+ },
744
+ {
745
+ key: "group-picker-cross-store",
746
+ label: "A group whose members belong to different sellers shows the read-only table with no quantity column and an explanation, rather than an \"Add selected\" button that always fails",
747
+ href: "/products/product-tester-standard-1",
748
+ },
749
+ {
750
+ key: "bundle-copies-stepper",
751
+ label: "A bundle page has a \"Copies\" +/- stepper and BOTH \"Buy now\" and \"Add to cart\" — Add to cart stays on the page, Buy now goes to checkout",
752
+ description: "A bundle is all-or-nothing: there must be no per-member quantity controls on the bundle page, only the copies stepper.",
753
+ href: "/bundles/bundle-tester-sandbox",
754
+ },
755
+ {
756
+ key: "bundle-line-editable-in-cart",
757
+ label: "A bundle in the cart shows its member list read-only and a copies stepper; changing copies recalculates the line at the bundle's discounted price, not the sum of member prices",
758
+ href: "/cart",
759
+ },
760
+ {
761
+ key: "grouped-listing-page-picker",
762
+ label: "A grouped listing has its own page with the same picker, reachable from the \"Pick items from this group →\" button on a group carousel",
763
+ href: "/groups/group-beyblade-original-lineage",
764
+ },
732
765
  { key: "apply-coupon", label: "Applying a coupon code at checkout works" },
733
766
  { key: "remove-coupon", label: "Removing an already-applied coupon from the cart recalculates the total back down" },
734
767
  { key: "remove-item", label: "Removing an item from the cart works" },
package/dist/index.d.ts CHANGED
@@ -1551,7 +1551,8 @@ export { updateBlogPostSchema } from "./features/blog/server";
1551
1551
  export type { BlogPostDetailResponse } from "./features/blog/server";
1552
1552
  export type { CreateBlogPostInput } from "./features/blog/server";
1553
1553
  export type { UpdateBlogPostInput } from "./features/blog/server";
1554
- export { addItemToCart, assertCanAddNewItems } from "./features/cart/server";
1554
+ export { addItemToCart, assertCanAddNewItems, addGroupLineToCart, updateCartGroupMembers, GROUP_LINE_MAX_MEMBERS, type AddGroupLineToCartInput, } from "./features/cart/server";
1555
+ export { toPublicGroupMember, toPublicGroupMembers, type PublicGroupMember, } from "./_internal/server/features/products/group-members";
1555
1556
  export { clearCart } from "./features/cart/server";
1556
1557
  export { getCart } from "./features/cart/server";
1557
1558
  export { mergeGuestCart } from "./features/cart/server";
@@ -3124,7 +3125,7 @@ export type { SessionUser } from "./react/index";
3124
3125
  export type { UseUrlTableOptions } from "./react/index";
3125
3126
  export { SUCCESS_MESSAGES } from "./values/index";
3126
3127
  export { splitCartIntoOrderGroups } from "./features/orders/index";
3127
- export { groupOrderItemsByBundle, type BundleOrderGroup, type OrderItemForBundleGrouping, } from "./features/orders/index";
3128
+ export { groupOrderItemsByLine, type LineOrderGroup, type OrderItemForLineGrouping, } from "./features/orders/index";
3128
3129
  export { buildSieveFilters } from "./utils/filter.helper";
3129
3130
  export { formatCurrency, formatFileSize } from "./utils/number.formatter";
3130
3131
  export { formatMonthYear } from "./utils/date.formatter";
package/dist/index.js CHANGED
@@ -2912,7 +2912,11 @@ export { updateBlogPostSchema } from "./features/blog/server";
2912
2912
  // [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
2913
2913
  // addItemToCart - Helper for add item to cart.
2914
2914
  // assertCanAddNewItems - the lane gate every add-to-cart entry point must run.
2915
- export { addItemToCart, assertCanAddNewItems } from "./features/cart/server";
2915
+ // addGroupLineToCart - add a buyer-assembled group selection as ONE line.
2916
+ export { addItemToCart, assertCanAddNewItems, addGroupLineToCart, updateCartGroupMembers, GROUP_LINE_MAX_MEMBERS, } from "./features/cart/server";
2917
+ // Public allow-list projection for a product-group member — see
2918
+ // § "Public Data Projections".
2919
+ export { toPublicGroupMember, toPublicGroupMembers, } from "./_internal/server/features/products/group-members";
2916
2920
  // [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
2917
2921
  // clearCart - Shared export for clear cart.
2918
2922
  export { clearCart } from "./features/cart/server";
@@ -5526,7 +5530,7 @@ export { SUCCESS_MESSAGES } from "./values/index";
5526
5530
  // -- Missing order utils
5527
5531
  export { splitCartIntoOrderGroups } from "./features/orders/index";
5528
5532
  // S-SBUNI-5 2026-05-13 â€" bundle-aware order item grouping for receipts.
5529
- export { groupOrderItemsByBundle, } from "./features/orders/index";
5533
+ export { groupOrderItemsByLine, } from "./features/orders/index";
5530
5534
  // -- Missing utils
5531
5535
  export { buildSieveFilters } from "./utils/filter.helper";
5532
5536
  export { formatCurrency, formatFileSize } from "./utils/number.formatter";
@@ -25,6 +25,12 @@ export declare const DEFAULT_ROUTE_MAP: {
25
25
  readonly PRE_ORDER_DETAIL: (id: string) => string;
26
26
  readonly BUNDLES: "/bundles";
27
27
  readonly BUNDLE_DETAIL: (slug: string) => string;
28
+ /**
29
+ * A `groupedListings` document's public page. Distinct from a product
30
+ * GROUP (`product.groupId`), whose canonical page is its parent product's
31
+ * detail page — see § "Bundles vs Product Groups vs Grouped Listings".
32
+ */
33
+ readonly GROUP_DETAIL: (slug: string) => string;
28
34
  readonly PRIZE_DRAWS: "/prize-draws";
29
35
  readonly PRIZE_DRAW_DETAIL: (slug: string) => string;
30
36
  readonly SELLER_GUIDE_BUNDLES: "/seller-guide/bundles";
@@ -407,6 +413,12 @@ export declare const ROUTES: {
407
413
  readonly PRE_ORDER_DETAIL: (id: string) => string;
408
414
  readonly BUNDLES: "/bundles";
409
415
  readonly BUNDLE_DETAIL: (slug: string) => string;
416
+ /**
417
+ * A `groupedListings` document's public page. Distinct from a product
418
+ * GROUP (`product.groupId`), whose canonical page is its parent product's
419
+ * detail page — see § "Bundles vs Product Groups vs Grouped Listings".
420
+ */
421
+ readonly GROUP_DETAIL: (slug: string) => string;
410
422
  readonly PRIZE_DRAWS: "/prize-draws";
411
423
  readonly PRIZE_DRAW_DETAIL: (slug: string) => string;
412
424
  readonly SELLER_GUIDE_BUNDLES: "/seller-guide/bundles";
@@ -14,6 +14,12 @@ export const DEFAULT_ROUTE_MAP = {
14
14
  // SB1-J — bundles + prize draws (additive, pages not yet built)
15
15
  BUNDLES: "/bundles",
16
16
  BUNDLE_DETAIL: (slug) => `/bundles/${slug}`,
17
+ /**
18
+ * A `groupedListings` document's public page. Distinct from a product
19
+ * GROUP (`product.groupId`), whose canonical page is its parent product's
20
+ * detail page — see § "Bundles vs Product Groups vs Grouped Listings".
21
+ */
22
+ GROUP_DETAIL: (slug) => `/groups/${slug}`,
17
23
  PRIZE_DRAWS: "/prize-draws",
18
24
  PRIZE_DRAW_DETAIL: (slug) => `/prize-draws/${slug}`,
19
25
  SELLER_GUIDE_BUNDLES: "/seller-guide/bundles",