@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.
- package/dist/_internal/server/features/bundles/actions.d.ts +4 -1
- package/dist/_internal/server/features/bundles/actions.js +54 -5
- package/dist/_internal/server/features/checkout/actions.js +44 -73
- package/dist/_internal/server/features/checkout/bundle-expansion.d.ts +4 -6
- package/dist/_internal/server/features/checkout/bundle-expansion.js +19 -17
- package/dist/_internal/server/features/checkout/expand-order-items.d.ts +35 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +85 -0
- package/dist/_internal/server/features/checkout/stock-restore.js +6 -5
- package/dist/_internal/server/features/grouped/index.d.ts +2 -0
- package/dist/_internal/server/features/grouped/index.js +2 -0
- package/dist/_internal/server/features/grouped/metadata.d.ts +17 -0
- package/dist/_internal/server/features/grouped/metadata.js +48 -0
- package/dist/_internal/server/features/grouped/og.d.ts +16 -0
- package/dist/_internal/server/features/grouped/og.js +24 -0
- package/dist/_internal/server/features/products/group-members.d.ts +55 -0
- package/dist/_internal/server/features/products/group-members.js +41 -0
- package/dist/_internal/server/features/seo/sitemap.js +24 -1
- package/dist/_internal/shared/checkout/line-members.d.ts +60 -0
- package/dist/_internal/shared/checkout/line-members.js +68 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +63 -4
- package/dist/_internal/shared/checkout/order-math.js +119 -7
- package/dist/_internal/shared/checkout/rules/_registry.js +8 -2
- package/dist/client.d.ts +9 -3
- package/dist/client.js +4 -2
- package/dist/constants/api-endpoints.d.ts +21 -0
- package/dist/constants/api-endpoints.js +7 -0
- package/dist/features/admin/schemas/firestore.d.ts +10 -26
- package/dist/features/admin/schemas/firestore.js +10 -26
- package/dist/features/analytics/types.d.ts +1 -1
- package/dist/features/analytics/types.js +3 -0
- package/dist/features/cart/actions/cart-actions.d.ts +31 -1
- package/dist/features/cart/actions/cart-actions.js +163 -0
- package/dist/features/cart/components/CartGroupLineRow.d.ts +42 -0
- package/dist/features/cart/components/CartGroupLineRow.js +33 -0
- package/dist/features/cart/components/index.d.ts +2 -0
- package/dist/features/cart/components/index.js +1 -0
- package/dist/features/cart/repository/cart.repository.d.ts +33 -1
- package/dist/features/cart/repository/cart.repository.js +152 -2
- package/dist/features/cart/schemas/firestore.d.ts +7 -2
- package/dist/features/categories/components/BundleBuyNowCta.d.ts +18 -5
- package/dist/features/categories/components/BundleBuyNowCta.js +24 -10
- package/dist/features/categories/components/BundleDetailView.d.ts +8 -1
- package/dist/features/categories/components/BundleDetailView.js +2 -2
- package/dist/features/categories/components/BundlesListView.d.ts +1 -0
- package/dist/features/categories/components/CategoryBundlesListing.d.ts +3 -0
- package/dist/features/grouped/components/GroupedListingDetailView.d.ts +21 -0
- package/dist/features/grouped/components/GroupedListingDetailView.js +17 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.js +6 -1
- package/dist/features/orders/index.d.ts +1 -1
- package/dist/features/orders/index.js +1 -1
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/orders/utils/bundle-grouping.d.ts +29 -19
- package/dist/features/orders/utils/bundle-grouping.js +32 -22
- package/dist/features/products/components/GroupMemberPicker.d.ts +46 -0
- package/dist/features/products/components/GroupMemberPicker.js +108 -0
- package/dist/features/products/components/ShowGroupSection.js +7 -9
- package/dist/features/tester/seed-data/categories-tester-seed-data.js +6 -0
- package/dist/features/tester/seed-data/products-tester-seed-data.js +44 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +33 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -2
- package/dist/next/routing/route-map.d.ts +12 -0
- package/dist/next/routing/route-map.js +6 -0
- package/dist/seed/notifications-seed-data.js +225 -0
- package/dist/seed/payouts-seed-data.js +47 -0
- package/dist/seed/scammers-seed-data.js +34 -0
- package/dist/seed/stores-seed-data.js +34 -0
- package/dist/seed/support-tickets-seed-data.js +34 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +1 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +5 -1
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- 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
|
|
11
|
-
* 1. ONE
|
|
12
|
-
* describe the bundle
|
|
13
|
-
* bundle
|
|
14
|
-
*
|
|
15
|
-
* 2.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
31
|
+
export function groupOrderItemsByLine(items) {
|
|
28
32
|
const groups = [];
|
|
29
|
-
const
|
|
33
|
+
const indexByKey = new Map();
|
|
30
34
|
items.forEach((item, index) => {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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",
|