@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
@@ -1,28 +1,40 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  /**
4
- * BundleBuyNowCta — direct-checkout CTA for bundle detail pages.
4
+ * BundleBuyNowCta — purchase CTAs for bundle detail pages.
5
5
  *
6
- * Replaces BundleAddToCartCta (deleted). Bundles bypass the cart and go
7
- * straight to checkout via the `directBundleCheckoutAction` server action.
8
- * No qty input — bundles are always qty=1 per checkout.
6
+ * A bundle is ALL-OR-NOTHING: its members are not pickable, and the only
7
+ * quantity the buyer controls is how many COPIES of the whole bundle they want.
8
+ * That is why there is one stepper here and no per-member controls anywhere on
9
+ * the bundle page — the mirror image of `GroupMemberPicker`, which has
10
+ * per-member steppers and no line-level one.
11
+ *
12
+ * Two CTAs, not one. "Buy now" adds and jumps straight to checkout (the
13
+ * pre-existing express path). "Add to cart" adds and stays — without it a
14
+ * bundle could never sit in the cart to be edited, because the only route in
15
+ * pushed the buyer past the cart immediately.
9
16
  */
10
17
  import { useState, useCallback } from "react";
11
- import { Button, LoginRequiredModal, Stack, Text } from "../../../ui";
18
+ import { Button, LoginRequiredModal, Row, Stack, Text } from "../../../ui";
19
+ import { QuantityStepper } from "../../../ui/components/QuantityStepper";
12
20
  import { useToast } from "../../../ui";
13
21
  import { isAuthError } from "../../../utils/auth-error";
22
+ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
14
23
  import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy";
15
24
  import { normalizeError } from "../../../errors/normalize";
16
- export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, compact = false, }) {
25
+ export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, onAddToCart, compact = false, }) {
17
26
  const { showToast } = useToast();
18
27
  const [submitting, setSubmitting] = useState(false);
28
+ const [quantity, setQuantity] = useState(1);
19
29
  const [error, setError] = useState(null);
20
30
  const [showLoginModal, setShowLoginModal] = useState(false);
21
- const handleClick = useCallback(async () => {
31
+ const run = useCallback(async (fn, successMsg) => {
22
32
  setError(null);
23
33
  setSubmitting(true);
24
34
  try {
25
- await onBuyNow({ bundleSlug });
35
+ await fn({ bundleSlug, quantity });
36
+ if (successMsg)
37
+ showToast(successMsg, "success");
26
38
  }
27
39
  catch (err) {
28
40
  void normalizeError(err);
@@ -38,9 +50,11 @@ export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, comp
38
50
  finally {
39
51
  setSubmitting(false);
40
52
  }
41
- }, [bundleSlug, onBuyNow, showToast]);
53
+ }, [bundleSlug, quantity, showToast]);
54
+ const handleClick = useCallback(() => run(onBuyNow), [run, onBuyNow]);
55
+ const handleAddToCart = useCallback(() => (onAddToCart ? run(onAddToCart, "Bundle added to your cart.") : Promise.resolve()), [run, onAddToCart]);
42
56
  if (outOfStock) {
43
57
  return (_jsxs(Stack, { gap: "xs", "aria-live": "polite", children: [_jsx(Button, { variant: "primary", size: compact ? "sm" : "md", disabled: true, "aria-disabled": true, className: compact ? "w-full" : undefined, children: BUNDLE_COPY.detail.ctaOutOfStock }), !compact && (_jsx(Text, { size: "xs", color: "muted", children: BUNDLE_COPY.detail.ctaHint }))] }));
44
58
  }
45
- return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to purchase this bundle. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", size: compact ? "sm" : "md", onClick: handleClick, disabled: submitting, "aria-busy": submitting, className: compact ? "w-full" : undefined, children: submitting ? BUNDLE_COPY.detail.ctaAdding : BUNDLE_COPY.detail.ctaBuyNow }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error }))] })] }));
59
+ return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to purchase this bundle. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "sm", children: [!compact && (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Text, { size: "sm", color: "muted", children: "Copies" }), _jsx(QuantityStepper, { value: quantity, onChange: setQuantity, min: 1, max: 99, disabled: submitting, size: "md", ariaLabel: "Number of copies of this bundle", decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })] })), _jsxs(Row, { gap: "sm", wrap: true, className: compact ? "w-full" : undefined, children: [_jsx(Button, { variant: "primary", size: compact ? "sm" : "md", onClick: handleClick, disabled: submitting, "aria-busy": submitting, className: compact ? "w-full" : undefined, children: submitting ? BUNDLE_COPY.detail.ctaAdding : BUNDLE_COPY.detail.ctaBuyNow }), onAddToCart && !compact && (_jsx(Button, { action: ACTIONS.CART["add-bundle-to-cart"], variant: "outline", size: "md", onClick: handleAddToCart, disabled: submitting, "aria-busy": submitting }))] }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error }))] })] }));
46
60
  }
@@ -17,8 +17,15 @@ export interface BundleDetailViewProps {
17
17
  /** Direct-checkout callback (S-SBUNI-RULES). When supplied, renders the "Buy now" CTA. */
18
18
  onBuyNow?: (input: {
19
19
  bundleSlug: string;
20
+ quantity: number;
21
+ }) => Promise<unknown>;
22
+ /** Adds the bundle without leaving the page — the only way it can reach the
23
+ * cart and stay there to be edited. */
24
+ onAddToCart?: (input: {
25
+ bundleSlug: string;
26
+ quantity: number;
20
27
  }) => Promise<unknown>;
21
28
  /** Other active bundles, excluding this one — renders a "Related Bundles" section when non-empty. */
22
29
  relatedBundles?: CategoryDocument[];
23
30
  }
24
- export declare function BundleDetailView({ bundle, members, onBuyNow, relatedBundles, }: BundleDetailViewProps): React.JSX.Element;
31
+ export declare function BundleDetailView({ bundle, members, onBuyNow, onAddToCart, relatedBundles, }: BundleDetailViewProps): React.JSX.Element;
@@ -17,7 +17,7 @@ const STOCK_BADGE_TEXT = {
17
17
  out_of_stock: BUNDLE_COPY.stockBadge.out_of_stock,
18
18
  };
19
19
  const PLACEHOLDER_EMOJI = "📦";
20
- export function BundleDetailView({ bundle, members = [], onBuyNow, relatedBundles = [], }) {
20
+ export function BundleDetailView({ bundle, members = [], onBuyNow, onAddToCart, relatedBundles = [], }) {
21
21
  const memberCount = members.length || bundle.bundleProductIds?.length || 0;
22
22
  const stock = bundle.bundleStockStatus ?? "in_stock";
23
23
  const cover = bundle.display?.coverImage;
@@ -25,5 +25,5 @@ export function BundleDetailView({ bundle, members = [], onBuyNow, relatedBundle
25
25
  ? formatCurrency(bundle.bundlePrice, "INR")
26
26
  : BUNDLE_COPY.detail.priceFallback;
27
27
  const discount = computeBundleDiscount(bundle.bundlePrice, bundle.bundleOriginalTotal);
28
- return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
28
+ return (_jsxs(Main, { children: [_jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Stack, { gap: "lg", direction: "md-row", align: "start", children: [_jsx(Div, { className: "w-full md:w-2/5", children: members.length > 0 ? (_jsx(BundleCollage, { members: members })) : (_jsx(Div, { className: `aspect-video w-full ${__O.hidden}`, rounded: "2xl", surface: "subtle", children: _jsx(Div, { className: "h-full w-full", children: _jsx(MediaImage, { src: cover, alt: bundle.name, size: "hero", fallback: PLACEHOLDER_EMOJI }) }) })) }), _jsxs(Stack, { gap: "md", className: "flex-1", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "semibold", color: "primary", children: bundle.name }), _jsxs(Row, { gap: "sm", align: "center", wrap: true, children: [_jsx(Text, { size: "lg", weight: "bold", children: priceLabel }), discount && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", color: "muted", className: "line-through", children: formatCurrency(discount.originalTotal, "INR") }), _jsx(Badge, { variant: "success", children: BUNDLE_COPY.detail.discountBadge(discount.percent) })] })), _jsxs(Text, { size: "sm", color: "muted", children: ["\u00B7 ", BUNDLE_COPY.detail.itemCount(memberCount)] }), _jsx(Badge, { variant: BUNDLE_STOCK_VARIANT[stock], children: STOCK_BADGE_TEXT[stock] })] }), discount && (_jsx(Text, { size: "sm", color: "success", weight: "medium", children: BUNDLE_COPY.detail.savingsLabel(formatCurrency(discount.savings, "INR")) })), bundle.description && (_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 2, className: "text-[var(--appkit-color-text-muted)]", size: "sm", weight: "semibold", children: BUNDLE_COPY.detailDescriptionHeading }), _jsx(Text, { className: "whitespace-pre-line", children: bundle.description })] })), onBuyNow ? (_jsx(BundleBuyNowCta, { bundleSlug: bundle.slug, outOfStock: stock === "out_of_stock", onBuyNow: onBuyNow, onAddToCart: onAddToCart })) : (_jsxs(Div, { textSize: "sm", className: "border-dashed", border: "strong", surface: "muted", rounded: "xl", padding: "md", "aria-live": "polite", children: [_jsx(Text, { weight: "semibold", className: "mb-1 block", children: BUNDLE_COPY.detail.ctaDisabled }), _jsx(Text, { size: "sm", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }))] })] }), _jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 2, size: "xl", weight: "semibold", color: "primary", children: BUNDLE_COPY.detailItemsHeading }), members.length === 0 ? (_jsx(Text, { color: "muted", children: BUNDLE_COPY.detail.emptyMembers })) : (_jsx(Ul, { gap: "2", className: "grid grid-cols-1 sm:grid-cols-2", children: members.map((p, i) => (_jsxs(Li, { layout: "flex-center", gap: "2", rounded: "lg", border: "subtle", padding: "inlineSm", textSize: "sm", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "w-5", color: "faint", children: ["#", i + 1] }), _jsx(Link, { href: String(ROUTES.PUBLIC.PRODUCT_DETAIL?.(p.slug ?? p.id) ?? "#"), className: "flex-1 truncate font-medium hover:underline hover:text-primary", children: p.title }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(p.price ?? 0, p.currency ?? "INR") })] }, p.id))) }))] })] }) }) }), relatedBundles.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Bundles" }), _jsx(CategoryGrid, { categories: relatedBundles, getHref: (c) => String(ROUTES.PUBLIC.BUNDLE_DETAIL(c.slug)) })] }) }))] }));
29
29
  }
@@ -4,6 +4,7 @@ export interface BundlesListViewProps {
4
4
  searchParams?: SearchParams;
5
5
  onBuyNow?: (input: {
6
6
  bundleSlug: string;
7
+ quantity: number;
7
8
  }) => Promise<unknown>;
8
9
  }
9
10
  /**
@@ -3,8 +3,11 @@ import type { CategoryDocument } from "../schemas";
3
3
  export interface CategoryBundlesListingProps {
4
4
  initialBundles: CategoryDocument[];
5
5
  brandName?: string;
6
+ /** Cards render the CTA in `compact` mode, which has no stepper — quantity
7
+ * is always 1 from here. The full stepper lives on the bundle detail page. */
6
8
  onBuyNow?: (input: {
7
9
  bundleSlug: string;
10
+ quantity: number;
8
11
  }) => Promise<unknown>;
9
12
  }
10
13
  export declare function CategoryBundlesListing({ initialBundles, brandName, onBuyNow, }: CategoryBundlesListingProps): React.JSX.Element;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * GroupedListingDetailView — the public page for a `groupedListings` document.
3
+ *
4
+ * Before this, a grouped listing had no page at all: it existed only as a
5
+ * horizontal carousel on other pages, so there was nowhere to see the whole
6
+ * group and nothing to link to from a cart line built out of it.
7
+ *
8
+ * A grouped listing has NO price of its own — SB-UNI-V removed its pricing
9
+ * fields on purpose. That is not a gap to fill: under "pick as you wish" the
10
+ * price simply IS the sum of what the buyer selected, which is exactly what
11
+ * `GroupMemberPicker` computes and what `unitPriceFor` recomputes server-side.
12
+ */
13
+ import React from "react";
14
+ import { type GroupPickerMember } from "../../products/components/GroupMemberPicker";
15
+ import type { GroupedListingDocument } from "../schemas/firestore";
16
+ export interface GroupedListingDetailViewProps {
17
+ group: GroupedListingDocument;
18
+ /** Members, already projected for public consumption. */
19
+ members: GroupPickerMember[];
20
+ }
21
+ export declare function GroupedListingDetailView({ group, members }: GroupedListingDetailViewProps): React.JSX.Element;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Container, Div, Heading, Section, Span, Stack, Text, } from "../../../ui";
4
+ import { MediaImage } from "../../media/MediaImage";
5
+ import { GroupMemberPicker } from "../../products/components/GroupMemberPicker";
6
+ const __O = { hidden: "overflow-hidden" };
7
+ /** Human label per theme — mirrors GROUP_THEME_TITLE on the carousel. */
8
+ const THEME_LABEL = {
9
+ related: "Related items",
10
+ character: "Same character",
11
+ lineage: "Same lineage",
12
+ set: "Complete the set",
13
+ generic: "Grouped listing",
14
+ };
15
+ export function GroupedListingDetailView({ group, members }) {
16
+ return (_jsx(Section, { padding: "section", children: _jsx(Container, { children: _jsxs(Stack, { gap: "comfortable", children: [group.coverImage && (_jsx(Div, { className: `relative h-48 w-full md:h-64 ${__O.hidden}`, rounded: "xl", surface: "muted", children: _jsx(MediaImage, { src: group.coverImage, alt: group.title, size: "banner" }) })), _jsxs(Stack, { gap: "xs", children: [_jsx(Span, { size: "xs", color: "muted", transform: "uppercase", weight: "semibold", children: THEME_LABEL[group.groupTheme] }), _jsx(Heading, { level: 1, children: group.title }), group.description && (_jsx(Text, { color: "muted", children: group.description })), _jsxs(Text, { size: "sm", color: "muted", children: [members.length, " item", members.length === 1 ? "" : "s", " in this group"] })] }), members.length > 0 ? (_jsxs(Stack, { gap: "sm", children: [_jsx(Heading, { level: 2, size: "lg", children: "Pick what you want" }), _jsx(Text, { size: "sm", color: "muted", children: "Choose a quantity for each item \u2014 they'll go into your cart as a single line you can edit later." }), _jsx(GroupMemberPicker, { groupId: group.id, groupSource: "grouped-listing", members: members })] })) : (_jsx(Text, { color: "muted", children: "Nothing in this group is available right now." }))] }) }) }));
17
+ }
@@ -9,6 +9,7 @@ import { ProductCard } from "../../products/components/ProductGrid";
9
9
  import { toProductItem } from "../../../_internal/shared/features/products/to-product-item";
10
10
  import { CAROUSEL_PER_VIEW } from "../../homepage/constants/carousel-per-view";
11
11
  import { Stack } from "../../../ui";
12
+ import { ROUTES } from "../../../next/routing/route-map";
12
13
  const GROUP_THEME_TITLE = {
13
14
  related: "Other listings you might like",
14
15
  character: "Same character",
@@ -29,6 +30,10 @@ export function GroupedListingsCarousel({ groups }) {
29
30
  return null;
30
31
  return (_jsx(Stack, { gap: "xl", children: visibleGroups.map((group) => {
31
32
  const items = group.items.map((p) => toProductItem(p));
32
- return (_jsx(SectionCarousel, { title: group.title || GROUP_THEME_TITLE[group.groupTheme], pillLabel: "Grouped Listings", headingVariant: "editorial", items: items, isLoading: false, perView: CAROUSEL_PER_VIEW.standard, keyExtractor: (item) => item.id, renderItem: (item) => (_jsx(ProductCard, { product: item, href: item.slug || item.id ? pluginFor(item.listingType ?? "standard").detailRoute(item.slug || item.id) : undefined })) }, group.id));
33
+ return (_jsx(SectionCarousel, { title: group.title || GROUP_THEME_TITLE[group.groupTheme], pillLabel: "Grouped Listings", headingVariant: "editorial",
34
+ // The group now has a page of its own, where the buyer can pick
35
+ // several members and add them as one cart line. Until it existed,
36
+ // this carousel was the only way a grouped listing was ever seen.
37
+ viewMoreHref: group.slug ? String(ROUTES.PUBLIC.GROUP_DETAIL(group.slug)) : undefined, viewMoreLabel: "Pick items from this group \u2192", items: items, isLoading: false, perView: CAROUSEL_PER_VIEW.standard, keyExtractor: (item) => item.id, renderItem: (item) => (_jsx(ProductCard, { product: item, href: item.slug || item.id ? pluginFor(item.listingType ?? "standard").detailRoute(item.slug || item.id) : undefined })) }, group.id));
33
38
  }) }));
34
39
  }
@@ -4,5 +4,5 @@ export * from "./components";
4
4
  export * from "./schemas";
5
5
  export * from "./columns";
6
6
  export * from "./utils/order-splitter";
7
- export { groupOrderItemsByBundle, type BundleOrderGroup, type OrderItemForBundleGrouping, } from "./utils/bundle-grouping";
7
+ export { groupOrderItemsByLine, type LineOrderGroup, type OrderItemForLineGrouping, } from "./utils/bundle-grouping";
8
8
  export { manifest } from "./manifest";
@@ -5,5 +5,5 @@ export * from "./schemas";
5
5
  export * from "./columns";
6
6
  export * from "./utils/order-splitter";
7
7
  // S-SBUNI-5 2026-05-13 — bundle-aware order item grouping.
8
- export { groupOrderItemsByBundle, } from "./utils/bundle-grouping";
8
+ export { groupOrderItemsByLine, } from "./utils/bundle-grouping";
9
9
  export { manifest } from "./manifest";
@@ -188,7 +188,18 @@ export interface OrderDocumentItem {
188
188
  cancelledReason?: string;
189
189
  /** SB-UNI-5 — forwarded from the cart line when this order item is an expanded bundle, so the order-detail UI can collapse it back under a "Bundle: <name>" header and stock-restore logic can fan back out to member products. */
190
190
  bundleCategorySlug?: string;
191
+ /** @deprecated Legacy collapsed rows only — expanded rows carry one product each. */
191
192
  bundleProductIds?: string[];
193
+ /**
194
+ * Collapse key for a multi-member cart line, shared by every row the line
195
+ * expanded into. `groupOrderItemsByLine` groups on it so the receipt still
196
+ * reads as one "Bundle: X" / "Group: X" block.
197
+ */
198
+ groupSlug?: string;
199
+ /** Display name of the bundle / group these rows came from. */
200
+ groupTitle?: string;
201
+ /** Which kind of multi-member line produced these rows. */
202
+ groupSource?: "bundle" | "product-group" | "grouped-listing";
192
203
  /** P-8 GST — snapshotted from the product at order time so the invoice stays accurate even if the product's HSN/rate later changes. */
193
204
  hsnCode?: string;
194
205
  gstRate?: 0 | 5 | 12 | 18 | 28;
@@ -7,42 +7,52 @@
7
7
  * checkout receipt mirrors the buyer's intent. This helper is the pure
8
8
  * grouping logic — the UI primitive lives next to the consumer view.
9
9
  *
10
- * Two flavours of "bundle row" can appear on an order:
11
- * 1. ONE order row whose `bundleCategorySlug` + `bundleProductIds[]` set
12
- * describe the bundle (this is what S-SBUNI-4's checkout writes — the
13
- * bundle stays a single row in the order with the locked
14
- * bundlePrice as its line total).
15
- * 2. (Future) Expanded N rows, each tagged with the same
16
- * `bundleCategorySlug`. The grouping handles this too in case a future
17
- * session pivots to row expansion.
10
+ * Two flavours of multi-member row can appear on an order:
11
+ * 1. LEGACY — ONE row whose `bundleCategorySlug` + `bundleProductIds[]`
12
+ * describe the whole bundle, with the locked bundlePrice as its line
13
+ * total. Every bundle order placed before row expansion looks like this,
14
+ * and they keep rendering unchanged.
15
+ * 2. CURRENT — expanded N rows, one per member, all sharing a `groupSlug`
16
+ * (and, for bundles, the same `bundleCategorySlug`). This is what
17
+ * checkout writes now, so each row can carry its own HSN/GST and be
18
+ * cancelled independently.
18
19
  *
19
- * Either way, `groupOrderItemsByBundle` returns a stable, ordered list of
20
- * "groups" so the renderer can iterate once.
20
+ * Either way this returns a stable, ordered list of groups so the renderer
21
+ * iterates once and the buyer's receipt reads the same.
21
22
  */
22
23
  /** Minimal shape consumed from an order item (Firestore wire format). */
23
- export interface OrderItemForBundleGrouping {
24
- /** Bundle discriminator — set when this row is part of a bundle. */
24
+ export interface OrderItemForLineGrouping {
25
+ /** Collapse key for expanded rows (bundle OR buyer-assembled group). */
26
+ groupSlug?: string;
27
+ /** Display name of the bundle / group. */
28
+ groupTitle?: string;
29
+ /** Bundle discriminator — also set on expanded bundle rows. */
25
30
  bundleCategorySlug?: string;
26
- /** Snapshot of bundle members at order-creation time. */
31
+ /** @deprecated Legacy collapsed rows only. */
27
32
  bundleProductIds?: string[];
28
33
  }
29
- export type BundleOrderGroup<T extends OrderItemForBundleGrouping> = {
34
+ export type LineOrderGroup<T extends OrderItemForLineGrouping> = {
30
35
  kind: "single";
31
36
  item: T;
32
37
  index: number;
33
38
  } | {
34
39
  kind: "bundle";
40
+ /** The key these rows were collapsed on. */
35
41
  bundleCategorySlug: string;
42
+ groupTitle?: string;
36
43
  items: Array<{
37
44
  item: T;
38
45
  index: number;
39
46
  }>;
40
- /** Pulled from the first row in the group; reflects what checkout snapshotted. */
47
+ /** Members represented: the row count when expanded, the snapshot length when legacy. */
41
48
  memberCount: number;
42
49
  };
43
50
  /**
44
- * Walk the items array preserving original order; collapse contiguous (or
45
- * non-contiguous) bundle rows sharing the same `bundleCategorySlug` into a
46
- * single group. Single rows pass through unchanged.
51
+ * Walk the items array preserving original order; collapse rows sharing a
52
+ * collapse key into one group. Single rows pass through unchanged.
53
+ *
54
+ * The key is `groupSlug ?? bundleCategorySlug` — the fallback is what keeps
55
+ * legacy collapsed bundle rows (written before `groupSlug` existed) grouping
56
+ * exactly as they always did, with no order migration.
47
57
  */
48
- export declare function groupOrderItemsByBundle<T extends OrderItemForBundleGrouping>(items: T[]): Array<BundleOrderGroup<T>>;
58
+ export declare function groupOrderItemsByLine<T extends OrderItemForLineGrouping>(items: T[]): Array<LineOrderGroup<T>>;
@@ -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;
@@ -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,12 +268,151 @@ export const productsTesterSeedData = [
265
268
  customSections: [],
266
269
  isPromoted: false,
267
270
  isOnSale: false,
271
+ gstRate: 18,
272
+ hsnCode: "95030090",
268
273
  groupId: "group-tester-sandbox-bundle",
269
274
  groupParentSlug: "group-tester-sandbox-bundle",
270
275
  groupTitle: TESTER_GROUP_TITLE,
271
276
  createdAt: new Date(),
272
277
  updatedAt: new Date(),
273
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",
305
+ groupId: "group-tester-sandbox-bundle",
306
+ groupParentSlug: "group-tester-sandbox-bundle",
307
+ groupTitle: TESTER_GROUP_TITLE,
308
+ createdAt: new Date(),
309
+ updatedAt: new Date(),
310
+ }),
311
+ // ── Grouped-cart-line fixtures ───────────────────────────────────────────
312
+ // Each of the three below exists to make ONE picker branch testable by hand.
313
+ // Without them the branch can only be reached by editing data live, which a
314
+ // reseed silently wipes out.
315
+ withTokens({
316
+ id: "product-tester-group-soldout",
317
+ slug: "product-tester-group-soldout",
318
+ title: "Test Part — Sold Out (Group Member)",
319
+ description: "A member of the tester product group that is deliberately out of stock. Exists so the group picker's blocked-member branch has real data: it must render a reason chip instead of a +/- stepper, and must not be selectable. Auto-expires in 7 days.",
320
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
321
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
322
+ brandSlug: "brand-tester-sandbox",
323
+ brand: "TestBrand",
324
+ price: 129,
325
+ currency: "INR",
326
+ stockQuantity: 0,
327
+ availableQuantity: 0,
328
+ isSold: true,
329
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-group-soldout-20260101/900/900"),
330
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-group-soldout-20260101/900/900")],
331
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
332
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
333
+ listingType: "standard",
334
+ customFields: [],
335
+ customSections: [],
336
+ isPromoted: false,
337
+ isOnSale: false,
338
+ gstRate: 18,
339
+ hsnCode: "95030090",
340
+ groupId: "group-tester-sandbox-bundle",
341
+ groupParentSlug: "group-tester-sandbox-bundle",
342
+ groupTitle: TESTER_GROUP_TITLE,
343
+ createdAt: new Date(),
344
+ updatedAt: new Date(),
345
+ }),
346
+ // A group whose members span TWO stores. `storeId` is the order-splitting key
347
+ // and the key per-store add-ons/coupons/shipping/payout hang off, so such a
348
+ // group can never become one cart line — the picker must stand down to a
349
+ // read-only table rather than offer a button that always fails.
350
+ // `withTokens` spreads `...p` last, so the storeId below overrides the
351
+ // sandbox default on purpose.
352
+ withTokens({
353
+ id: "product-tester-crossstore-a",
354
+ slug: "product-tester-crossstore-a",
355
+ title: "Test Cross-Store Set — Seller A",
356
+ description: "Parent of a deliberately CROSS-STORE tester group. Its sibling belongs to a different seller, so the group picker must render read-only with no quantity column and no \"Add selected\" button. Auto-expires in 7 days.",
357
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
358
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
359
+ brandSlug: "brand-tester-sandbox",
360
+ brand: "TestBrand",
361
+ price: 259,
362
+ currency: "INR",
363
+ stockQuantity: 6,
364
+ availableQuantity: 6,
365
+ isSold: false,
366
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-a-20260101/900/900"),
367
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-a-20260101/900/900")],
368
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
369
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
370
+ listingType: "standard",
371
+ customFields: [],
372
+ customSections: [],
373
+ isPromoted: false,
374
+ isOnSale: false,
375
+ gstRate: 18,
376
+ isGroupParent: true,
377
+ groupId: "group-tester-cross-store",
378
+ groupTitle: "Tester Cross-Store Set",
379
+ groupChildSlugs: ["product-tester-crossstore-b"],
380
+ createdAt: new Date(),
381
+ updatedAt: new Date(),
382
+ }),
383
+ withTokens({
384
+ id: "product-tester-crossstore-b",
385
+ slug: "product-tester-crossstore-b",
386
+ title: "Test Cross-Store Set — Seller B",
387
+ description: "Sibling of product-tester-crossstore-a, deliberately owned by a DIFFERENT store so the group spans two sellers. Still isTestData, so filterTestDataForViewer keeps it off that store's public listings for non-testers. Auto-expires in 7 days.",
388
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
389
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
390
+ brandSlug: "brand-tester-sandbox",
391
+ brand: "TestBrand",
392
+ price: 189,
393
+ currency: "INR",
394
+ stockQuantity: 6,
395
+ availableQuantity: 6,
396
+ isSold: false,
397
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-b-20260101/900/900"),
398
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-b-20260101/900/900")],
399
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
400
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
401
+ listingType: "standard",
402
+ customFields: [],
403
+ customSections: [],
404
+ isPromoted: false,
405
+ isOnSale: false,
406
+ gstRate: 12,
407
+ // The whole point of this fixture — a different seller from its sibling.
408
+ storeId: "store-beyblade-arena",
409
+ storeName: "Beyblade Arena",
410
+ groupId: "group-tester-cross-store",
411
+ groupParentSlug: "product-tester-crossstore-a",
412
+ groupTitle: "Tester Cross-Store Set",
413
+ createdAt: new Date(),
414
+ updatedAt: new Date(),
415
+ }),
274
416
  withTokens({
275
417
  id: "product-tester-standard-sold",
276
418
  slug: "product-tester-standard-sold",
@@ -644,7 +786,14 @@ export const productsTesterSeedData = [
644
786
  isGroupParent: true,
645
787
  groupId: "group-tester-sandbox-bundle",
646
788
  groupTitle: TESTER_GROUP_TITLE,
647
- groupChildSlugs: ["product-tester-standard-1", "product-tester-standard-2"],
789
+ // Parent↔child mirror: every id here must also carry `groupId` on its own
790
+ // document. The pair drifting is Root Cause #42's second instance.
791
+ groupChildSlugs: [
792
+ "product-tester-standard-1",
793
+ "product-tester-standard-2",
794
+ "product-tester-standard-3",
795
+ "product-tester-group-soldout",
796
+ ],
648
797
  createdAt: new Date(),
649
798
  updatedAt: new Date(),
650
799
  }),