@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
|
@@ -239,6 +239,13 @@ export declare const CART_ENDPOINTS: {
|
|
|
239
239
|
/** Per-store paid add-on selections — the source of truth for what's charged. */
|
|
240
240
|
readonly ADDONS: "/api/cart/addons";
|
|
241
241
|
readonly COUPON: "/api/cart/coupon";
|
|
242
|
+
/**
|
|
243
|
+
* Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
|
|
244
|
+
* POST on purpose: that is the hot path behind every product card's
|
|
245
|
+
* Add-to-cart, and widening its `{productId, quantity}` schema into a
|
|
246
|
+
* discriminated union would put this feature's blast radius on it.
|
|
247
|
+
*/
|
|
248
|
+
readonly GROUP: "/api/cart/group";
|
|
242
249
|
};
|
|
243
250
|
export declare const CATEGORY_ENDPOINTS: {
|
|
244
251
|
readonly LIST: "/api/categories";
|
|
@@ -695,6 +702,13 @@ export declare const API_ENDPOINTS: {
|
|
|
695
702
|
/** Per-store paid add-on selections — the source of truth for what's charged. */
|
|
696
703
|
readonly ADDONS: "/api/cart/addons";
|
|
697
704
|
readonly COUPON: "/api/cart/coupon";
|
|
705
|
+
/**
|
|
706
|
+
* Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
|
|
707
|
+
* POST on purpose: that is the hot path behind every product card's
|
|
708
|
+
* Add-to-cart, and widening its `{productId, quantity}` schema into a
|
|
709
|
+
* discriminated union would put this feature's blast radius on it.
|
|
710
|
+
*/
|
|
711
|
+
readonly GROUP: "/api/cart/group";
|
|
698
712
|
};
|
|
699
713
|
readonly CATEGORIES: {
|
|
700
714
|
readonly LIST: "/api/categories";
|
|
@@ -1153,6 +1167,13 @@ export declare const API_ROUTES: {
|
|
|
1153
1167
|
/** Per-store paid add-on selections — the source of truth for what's charged. */
|
|
1154
1168
|
readonly ADDONS: "/api/cart/addons";
|
|
1155
1169
|
readonly COUPON: "/api/cart/coupon";
|
|
1170
|
+
/**
|
|
1171
|
+
* Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
|
|
1172
|
+
* POST on purpose: that is the hot path behind every product card's
|
|
1173
|
+
* Add-to-cart, and widening its `{productId, quantity}` schema into a
|
|
1174
|
+
* discriminated union would put this feature's blast radius on it.
|
|
1175
|
+
*/
|
|
1176
|
+
readonly GROUP: "/api/cart/group";
|
|
1156
1177
|
};
|
|
1157
1178
|
readonly CATEGORIES: {
|
|
1158
1179
|
readonly LIST: "/api/categories";
|
|
@@ -279,6 +279,13 @@ export const CART_ENDPOINTS = {
|
|
|
279
279
|
/** Per-store paid add-on selections — the source of truth for what's charged. */
|
|
280
280
|
ADDONS: "/api/cart/addons",
|
|
281
281
|
COUPON: "/api/cart/coupon",
|
|
282
|
+
/**
|
|
283
|
+
* Add a buyer-assembled group selection as ONE line. Separate from `GET`'s
|
|
284
|
+
* POST on purpose: that is the hot path behind every product card's
|
|
285
|
+
* Add-to-cart, and widening its `{productId, quantity}` schema into a
|
|
286
|
+
* discriminated union would put this feature's blast radius on it.
|
|
287
|
+
*/
|
|
288
|
+
GROUP: "/api/cart/group",
|
|
282
289
|
};
|
|
283
290
|
// ---------------------------------------------------------------------------
|
|
284
291
|
// Categories
|
|
@@ -54,32 +54,16 @@ export declare const NOTIFICATION_FIELDS: {
|
|
|
54
54
|
readonly RELATED_TYPE: "relatedType";
|
|
55
55
|
readonly CREATED_AT: "createdAt";
|
|
56
56
|
readonly UPDATED_AT: "updatedAt";
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
readonly AUCTION_ENDED: NotificationType;
|
|
68
|
-
readonly REVIEW_APPROVED: NotificationType;
|
|
69
|
-
readonly REVIEW_REPLIED: NotificationType;
|
|
70
|
-
readonly PRODUCT_AVAILABLE: NotificationType;
|
|
71
|
-
readonly PROMOTION: NotificationType;
|
|
72
|
-
readonly SYSTEM: NotificationType;
|
|
73
|
-
readonly WELCOME: NotificationType;
|
|
74
|
-
readonly OFFER_RECEIVED: NotificationType;
|
|
75
|
-
readonly OFFER_RESPONDED: NotificationType;
|
|
76
|
-
readonly OFFER_EXPIRED: NotificationType;
|
|
77
|
-
readonly OFFER_COUNTER_ACCEPTED: NotificationType;
|
|
78
|
-
readonly REFUND_INITIATED: NotificationType;
|
|
79
|
-
readonly ACCOUNT_ACTION: NotificationType;
|
|
80
|
-
readonly PRIZE_WON: NotificationType;
|
|
81
|
-
readonly PRIZE_REVEAL_EXPIRED: NotificationType;
|
|
82
|
-
};
|
|
57
|
+
/**
|
|
58
|
+
* SCREAMING_SNAKE accessors, DERIVED from `NOTIFICATION_TYPE_VALUES`.
|
|
59
|
+
*
|
|
60
|
+
* This was a fifth hand-written copy of the union — 24 of the 28 values,
|
|
61
|
+
* missing `emi_installment_due_soon`, `emi_installment_overdue`,
|
|
62
|
+
* `payment_review` and `catalogue_images_stale` — and every entry carried an
|
|
63
|
+
* `as NotificationType` cast, which is precisely the assertion that lets a
|
|
64
|
+
* wrong string through. Built from the array now, so it cannot be short.
|
|
65
|
+
*/
|
|
66
|
+
readonly TYPE_VALUES: { [K in NotificationType as Uppercase<K>]: K; };
|
|
83
67
|
readonly PRIORITY_VALUES: {
|
|
84
68
|
readonly LOW: NotificationPriority;
|
|
85
69
|
readonly NORMAL: NotificationPriority;
|
|
@@ -110,32 +110,16 @@ export const NOTIFICATION_FIELDS = {
|
|
|
110
110
|
RELATED_TYPE: "relatedType",
|
|
111
111
|
CREATED_AT: "createdAt",
|
|
112
112
|
UPDATED_AT: "updatedAt",
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
AUCTION_ENDED: "auction_ended",
|
|
124
|
-
REVIEW_APPROVED: "review_approved",
|
|
125
|
-
REVIEW_REPLIED: "review_replied",
|
|
126
|
-
PRODUCT_AVAILABLE: "product_available",
|
|
127
|
-
PROMOTION: "promotion",
|
|
128
|
-
SYSTEM: "system",
|
|
129
|
-
WELCOME: "welcome",
|
|
130
|
-
OFFER_RECEIVED: "offer_received",
|
|
131
|
-
OFFER_RESPONDED: "offer_responded",
|
|
132
|
-
OFFER_EXPIRED: "offer_expired",
|
|
133
|
-
OFFER_COUNTER_ACCEPTED: "offer_counter_accepted",
|
|
134
|
-
REFUND_INITIATED: "refund_initiated",
|
|
135
|
-
ACCOUNT_ACTION: "account_action",
|
|
136
|
-
PRIZE_WON: "prize_won",
|
|
137
|
-
PRIZE_REVEAL_EXPIRED: "prize_reveal_expired",
|
|
138
|
-
},
|
|
113
|
+
/**
|
|
114
|
+
* SCREAMING_SNAKE accessors, DERIVED from `NOTIFICATION_TYPE_VALUES`.
|
|
115
|
+
*
|
|
116
|
+
* This was a fifth hand-written copy of the union — 24 of the 28 values,
|
|
117
|
+
* missing `emi_installment_due_soon`, `emi_installment_overdue`,
|
|
118
|
+
* `payment_review` and `catalogue_images_stale` — and every entry carried an
|
|
119
|
+
* `as NotificationType` cast, which is precisely the assertion that lets a
|
|
120
|
+
* wrong string through. Built from the array now, so it cannot be short.
|
|
121
|
+
*/
|
|
122
|
+
TYPE_VALUES: Object.fromEntries(NOTIFICATION_TYPE_VALUES.map((v) => [v.toUpperCase(), v])),
|
|
139
123
|
PRIORITY_VALUES: {
|
|
140
124
|
LOW: "low",
|
|
141
125
|
NORMAL: "normal",
|
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* page-views.repository.ts (server-only, imports firebase-admin) so the
|
|
3
3
|
* client-side tracker component can import just this without pulling
|
|
4
4
|
* firebase-admin into the client bundle. */
|
|
5
|
-
export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle"];
|
|
5
|
+
export declare const PAGE_VIEW_ENTITY_TYPES: readonly ["product", "store", "category", "homepage", "auction", "review", "user-profile", "blog", "event", "pre-order", "prize-draw", "classified", "digital-code", "live", "bundle", "grouped-listing"];
|
|
6
6
|
export type PageViewEntityType = (typeof PAGE_VIEW_ENTITY_TYPES)[number];
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Pure business functions — auth, rate-limiting, and Next.js specifics
|
|
5
5
|
* are handled by the calling server action in the consumer.
|
|
6
6
|
*/
|
|
7
|
-
import type { AddToCartInput, CartDocument, UpdateCartItemInput } from "../schemas";
|
|
7
|
+
import type { AddToCartInput, CartDocument, CartGroupSource, UpdateCartItemInput, UpdateCartGroupMembersInput } from "../schemas";
|
|
8
8
|
/**
|
|
9
9
|
* Lane gate. While a higher-obligation lane (a won auction, then an accepted
|
|
10
10
|
* offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
|
|
@@ -24,6 +24,30 @@ export declare function assertCanAddNewItems(userId: string): Promise<CartDocume
|
|
|
24
24
|
export declare function addItemToCart(userId: string, input: AddToCartInput): Promise<CartDocument>;
|
|
25
25
|
/** Error code the client maps to a lane-aware message. */
|
|
26
26
|
export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
|
|
27
|
+
/** Max members one grouped line may hold. Keeps the batch read bounded (Rule #6). */
|
|
28
|
+
export declare const GROUP_LINE_MAX_MEMBERS = 20;
|
|
29
|
+
export interface AddGroupLineToCartInput {
|
|
30
|
+
/** `product.groupId` for a product group, or the `groupedListings` doc id. */
|
|
31
|
+
groupId: string;
|
|
32
|
+
groupSource: CartGroupSource;
|
|
33
|
+
/** Ids and quantities ONLY — every other field is re-resolved server-side. */
|
|
34
|
+
members: Array<{
|
|
35
|
+
productId: string;
|
|
36
|
+
quantity: number;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Add a buyer-assembled selection of group members as ONE cart line.
|
|
41
|
+
*
|
|
42
|
+
* The client sends ids and quantities and nothing else. Accepting a price, a
|
|
43
|
+
* title or an image from the browser would be a ₹1-group exploit; every
|
|
44
|
+
* snapshot below is read from Firestore here.
|
|
45
|
+
*
|
|
46
|
+
* A rejected member fails the WHOLE request, naming the offender. Silently
|
|
47
|
+
* dropping it would hand the buyer a line that quietly isn't what they picked —
|
|
48
|
+
* the "wrong data, no error" failure nobody can report.
|
|
49
|
+
*/
|
|
50
|
+
export declare function addGroupLineToCart(userId: string, input: AddGroupLineToCartInput): Promise<CartDocument>;
|
|
27
51
|
/**
|
|
28
52
|
* Won-auction lines are non-removable and fixed-quantity — the buyer committed
|
|
29
53
|
* to the purchase by bidding, and letting them delete it would turn every
|
|
@@ -34,6 +58,12 @@ export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
|
|
|
34
58
|
* and the offer simply lapses at its `checkoutDeadline`.
|
|
35
59
|
*/
|
|
36
60
|
export declare function updateCartItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
|
|
61
|
+
/**
|
|
62
|
+
* Replace a grouped line's member quantities. A member set to 0 is dropped;
|
|
63
|
+
* dropping the last one removes the line (enforced in the repository so a
|
|
64
|
+
* forgetful caller can't leave an empty ghost line behind).
|
|
65
|
+
*/
|
|
66
|
+
export declare function updateCartGroupMembers(userId: string, itemId: string, input: UpdateCartGroupMembersInput): Promise<CartDocument>;
|
|
37
67
|
export declare function removeCartItem(userId: string, itemId: string): Promise<CartDocument>;
|
|
38
68
|
export declare function clearCart(userId: string): Promise<CartDocument>;
|
|
39
69
|
export declare function mergeGuestCart(userId: string, items: Array<{
|
|
@@ -12,6 +12,8 @@ import { storeRepository } from "../../stores/repository/store.repository";
|
|
|
12
12
|
import { normalizeListingType } from "../../products/utils/listing-type";
|
|
13
13
|
import { getDefaultCurrency } from "../../../core/baseline-resolver";
|
|
14
14
|
import { CART_LANE, activeLane, canAddNewItems, } from "../../../_internal/shared/checkout/lanes";
|
|
15
|
+
import { CART_MAX_ITEMS } from "../../../constants/limits";
|
|
16
|
+
import { getExpandedDecrements, validateCartItemStock, } from "../../../_internal/server/features/checkout/bundle-expansion";
|
|
15
17
|
/**
|
|
16
18
|
* Product documents don't always carry a denormalized `storeName` — the
|
|
17
19
|
* create routes only set it when the caller explicitly passes one, so
|
|
@@ -67,6 +69,159 @@ export async function addItemToCart(userId, input) {
|
|
|
67
69
|
}
|
|
68
70
|
/** Error code the client maps to a lane-aware message. */
|
|
69
71
|
export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
|
|
72
|
+
/** Max members one grouped line may hold. Keeps the batch read bounded (Rule #6). */
|
|
73
|
+
export const GROUP_LINE_MAX_MEMBERS = 20;
|
|
74
|
+
/**
|
|
75
|
+
* Add a buyer-assembled selection of group members as ONE cart line.
|
|
76
|
+
*
|
|
77
|
+
* The client sends ids and quantities and nothing else. Accepting a price, a
|
|
78
|
+
* title or an image from the browser would be a ₹1-group exploit; every
|
|
79
|
+
* snapshot below is read from Firestore here.
|
|
80
|
+
*
|
|
81
|
+
* A rejected member fails the WHOLE request, naming the offender. Silently
|
|
82
|
+
* dropping it would hand the buyer a line that quietly isn't what they picked —
|
|
83
|
+
* the "wrong data, no error" failure nobody can report.
|
|
84
|
+
*/
|
|
85
|
+
export async function addGroupLineToCart(userId, input) {
|
|
86
|
+
const { groupId, groupSource, members } = input;
|
|
87
|
+
if (!groupId)
|
|
88
|
+
throw new ValidationError("A group id is required.");
|
|
89
|
+
if (!members.length)
|
|
90
|
+
throw new ValidationError("Select at least one item to add.");
|
|
91
|
+
if (members.length > GROUP_LINE_MAX_MEMBERS) {
|
|
92
|
+
throw new ValidationError(`A single group line can hold at most ${GROUP_LINE_MAX_MEMBERS} different items.`);
|
|
93
|
+
}
|
|
94
|
+
for (const m of members) {
|
|
95
|
+
if (!Number.isInteger(m.quantity) || m.quantity < 1) {
|
|
96
|
+
throw new ValidationError("Every selected item needs a whole quantity of at least 1.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (new Set(members.map((m) => m.productId)).size !== members.length) {
|
|
100
|
+
throw new ValidationError("The same item was selected twice.");
|
|
101
|
+
}
|
|
102
|
+
const cart = await assertCanAddNewItems(userId);
|
|
103
|
+
// One batched read for the whole selection — capped at 20 above, so well
|
|
104
|
+
// inside the per-request Firestore budget.
|
|
105
|
+
const docs = await Promise.all(members.map((m) => productRepository.findById(m.productId).catch(() => null)));
|
|
106
|
+
const productById = new Map(members.map((m, i) => [m.productId, docs[i]]));
|
|
107
|
+
const { getListingRule } = await import("../../../_internal/shared/checkout/rules");
|
|
108
|
+
const { ProductStatusValues } = await import("../../products/schemas/firestore");
|
|
109
|
+
const resolved = [];
|
|
110
|
+
for (const m of members) {
|
|
111
|
+
const product = productById.get(m.productId);
|
|
112
|
+
if (!product) {
|
|
113
|
+
throw new ValidationError(`One of the selected items no longer exists.`);
|
|
114
|
+
}
|
|
115
|
+
if (product.status !== ProductStatusValues.PUBLISHED || product.isSold) {
|
|
116
|
+
throw new ValidationError(`"${product.title}" is no longer available.`);
|
|
117
|
+
}
|
|
118
|
+
// Membership. Without this a caller could staple arbitrary products into
|
|
119
|
+
// one line and buy them under a group's identity.
|
|
120
|
+
const belongs = groupSource === "product-group"
|
|
121
|
+
? product.groupId === groupId
|
|
122
|
+
: await isGroupedListingMember(groupId, product.id);
|
|
123
|
+
if (!belongs) {
|
|
124
|
+
throw new ValidationError(`"${product.title}" is not part of this group.`);
|
|
125
|
+
}
|
|
126
|
+
const listingType = normalizeListingType(product);
|
|
127
|
+
const rule = getListingRule(listingType);
|
|
128
|
+
if (!rule.cartEligible) {
|
|
129
|
+
throw new ValidationError(`"${product.title}" can't be added to the cart. ${rule.cartIneligibleHint ?? ""}`.trim());
|
|
130
|
+
}
|
|
131
|
+
if ((product.availableQuantity ?? 0) < m.quantity) {
|
|
132
|
+
throw new ValidationError(`Only ${product.availableQuantity ?? 0} left of "${product.title}".`);
|
|
133
|
+
}
|
|
134
|
+
resolved.push({
|
|
135
|
+
productId: product.id,
|
|
136
|
+
quantity: m.quantity,
|
|
137
|
+
unitPrice: product.price,
|
|
138
|
+
title: product.title,
|
|
139
|
+
image: product.images?.[0] ?? product.mainImage,
|
|
140
|
+
gstRate: product.gstRate,
|
|
141
|
+
hsnCode: product.hsnCode,
|
|
142
|
+
listingType,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Single-store rule. `storeId` is the order-splitting key AND the key the
|
|
146
|
+
// per-store add-ons, coupons, shipping and payout all hang off, so a line
|
|
147
|
+
// spanning two stores would break five things at once. The picker hides
|
|
148
|
+
// itself for a cross-store group; this is the server-side half of that.
|
|
149
|
+
const storeIds = new Set(resolved.map((r) => productById.get(r.productId)?.storeId ?? ""));
|
|
150
|
+
if (storeIds.size > 1) {
|
|
151
|
+
throw new ValidationError("These items are sold by different sellers, so they can't be combined into one cart line.");
|
|
152
|
+
}
|
|
153
|
+
const storeId = [...storeIds][0] ?? "";
|
|
154
|
+
const first = productById.get(resolved[0].productId);
|
|
155
|
+
// A one-member selection is just an ordinary line: 1 item × qty N is exactly
|
|
156
|
+
// what a normal cart line already means, and routing it through the group
|
|
157
|
+
// path would create a second representation of the same thing.
|
|
158
|
+
if (resolved.length === 1) {
|
|
159
|
+
return addItemToCart(userId, {
|
|
160
|
+
productId: first.id,
|
|
161
|
+
productTitle: first.title,
|
|
162
|
+
productImage: first.images?.[0] ?? first.mainImage,
|
|
163
|
+
price: first.price,
|
|
164
|
+
currency: first.currency,
|
|
165
|
+
quantity: resolved[0].quantity,
|
|
166
|
+
storeId,
|
|
167
|
+
storeName: first.storeName ?? "",
|
|
168
|
+
listingType: resolved[0].listingType ?? "standard",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// A grouped line counts as ONE item against the distinct-items cap.
|
|
172
|
+
if (cart.items.length >= CART_MAX_ITEMS) {
|
|
173
|
+
throw new ValidationError(`Cart full (${cart.items.length}/${CART_MAX_ITEMS}). Remove items to add new ones.`);
|
|
174
|
+
}
|
|
175
|
+
// Same stock check checkout will run, so add-time and checkout-time agree by
|
|
176
|
+
// construction rather than by two similar-looking implementations.
|
|
177
|
+
const candidate = {
|
|
178
|
+
itemId: "candidate",
|
|
179
|
+
productId: groupId,
|
|
180
|
+
quantity: 1,
|
|
181
|
+
groupMembers: resolved,
|
|
182
|
+
};
|
|
183
|
+
const expansion = getExpandedDecrements([...cart.items, candidate]);
|
|
184
|
+
const shortfall = validateCartItemStock(candidate, new Map([...productById.entries()].filter(([, p]) => p !== null)), expansion.decrements);
|
|
185
|
+
if (shortfall) {
|
|
186
|
+
const short = productById.get(shortfall.productId);
|
|
187
|
+
throw new ValidationError(`Only ${shortfall.availableQty} left of "${short?.title ?? shortfall.productId}" once your cart is counted.`);
|
|
188
|
+
}
|
|
189
|
+
const groupTitle = await resolveGroupTitle(groupId, groupSource, first ?? null);
|
|
190
|
+
return cartRepository.addGroupLine(userId, {
|
|
191
|
+
productId: groupId,
|
|
192
|
+
productTitle: groupTitle,
|
|
193
|
+
productImage: resolved[0].image ?? "",
|
|
194
|
+
currency: first?.currency ?? getDefaultCurrency(),
|
|
195
|
+
// Pinned to 1: member quantities carry the whole selection, and a copies
|
|
196
|
+
// multiplier on top would double-count. See the invariant on CartLineMember.
|
|
197
|
+
quantity: 1,
|
|
198
|
+
storeId,
|
|
199
|
+
storeName: await resolveStoreName(storeId, first?.storeName),
|
|
200
|
+
listingType: "standard",
|
|
201
|
+
lineKind: "group",
|
|
202
|
+
groupSource,
|
|
203
|
+
groupId,
|
|
204
|
+
groupSlug: groupSource === "product-group" ? groupId : undefined,
|
|
205
|
+
groupTitle,
|
|
206
|
+
groupMembers: resolved,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Is this product listed on that `groupedListings` document? */
|
|
210
|
+
async function isGroupedListingMember(groupId, productId) {
|
|
211
|
+
const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
|
|
212
|
+
const group = await groupedListingsRepository.findById(groupId).catch(() => null);
|
|
213
|
+
return Boolean(group?.productIds?.includes(productId));
|
|
214
|
+
}
|
|
215
|
+
/** Display name for the line. Falls back to the parent product's group title. */
|
|
216
|
+
async function resolveGroupTitle(groupId, groupSource, sample) {
|
|
217
|
+
if (groupSource === "grouped-listing") {
|
|
218
|
+
const { groupedListingsRepository } = await import("../../grouped/repository/grouped-listings.repository");
|
|
219
|
+
const group = await groupedListingsRepository.findById(groupId).catch(() => null);
|
|
220
|
+
if (group?.title)
|
|
221
|
+
return group.title;
|
|
222
|
+
}
|
|
223
|
+
return sample?.groupTitle ?? "Selected items";
|
|
224
|
+
}
|
|
70
225
|
/**
|
|
71
226
|
* Won-auction lines are non-removable and fixed-quantity — the buyer committed
|
|
72
227
|
* to the purchase by bidding, and letting them delete it would turn every
|
|
@@ -79,6 +234,14 @@ export const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
|
|
|
79
234
|
export async function updateCartItem(userId, itemId, input) {
|
|
80
235
|
return cartRepository.updateItem(userId, itemId, input);
|
|
81
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Replace a grouped line's member quantities. A member set to 0 is dropped;
|
|
239
|
+
* dropping the last one removes the line (enforced in the repository so a
|
|
240
|
+
* forgetful caller can't leave an empty ghost line behind).
|
|
241
|
+
*/
|
|
242
|
+
export async function updateCartGroupMembers(userId, itemId, input) {
|
|
243
|
+
return cartRepository.updateGroupMembers(userId, itemId, input);
|
|
244
|
+
}
|
|
82
245
|
export async function removeCartItem(userId, itemId) {
|
|
83
246
|
return cartRepository.removeItem(userId, itemId);
|
|
84
247
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CartGroupLineRow — one cart line that stands for several products.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes behind one component, because the difference is exactly the
|
|
5
|
+
* bundle-vs-group distinction and putting them in separate files would let it
|
|
6
|
+
* drift:
|
|
7
|
+
*
|
|
8
|
+
* BUNDLE — all-or-nothing. Members render read-only; the only stepper is the
|
|
9
|
+
* line's own and it means COPIES OF THE BUNDLE.
|
|
10
|
+
* GROUP — pick-as-you-wish. One stepper PER MEMBER, and no line-level
|
|
11
|
+
* stepper at all (a group line's `quantity` is pinned to 1, so a
|
|
12
|
+
* second multiplier would double-count).
|
|
13
|
+
*
|
|
14
|
+
* Never render both steppers. That is the invariant on `CartLineMember`
|
|
15
|
+
* expressed in UI.
|
|
16
|
+
*/
|
|
17
|
+
import React from "react";
|
|
18
|
+
import type { CartLineMember } from "../schemas";
|
|
19
|
+
export interface CartGroupLineRowProps {
|
|
20
|
+
/** Line identity (`CartItemDocument.itemId`). */
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
image?: string;
|
|
24
|
+
currency?: string;
|
|
25
|
+
/** `"bundle"` → copies stepper; `"group"` → per-member steppers. */
|
|
26
|
+
lineKind: "bundle" | "group";
|
|
27
|
+
members: CartLineMember[];
|
|
28
|
+
/** Copies of the whole line. Bundles only — a group line is always 1. */
|
|
29
|
+
quantity: number;
|
|
30
|
+
/** Detail href for the bundle / group itself. */
|
|
31
|
+
href?: string;
|
|
32
|
+
locked?: boolean;
|
|
33
|
+
/** Bundle only: change the number of copies. */
|
|
34
|
+
onQtyChange?: (id: string, qty: number) => void;
|
|
35
|
+
/** Group only: change one member's per-copy quantity (0 removes it). */
|
|
36
|
+
onMemberQtyChange?: (id: string, productId: string, qty: number) => void;
|
|
37
|
+
onRemove?: (id: string) => void;
|
|
38
|
+
/** Per-member stock ceiling, keyed by productId. */
|
|
39
|
+
maxByProductId?: Record<string, number>;
|
|
40
|
+
variant?: "card" | "row";
|
|
41
|
+
}
|
|
42
|
+
export declare function CartGroupLineRow({ id, title, image, currency, lineKind, members, quantity, href, locked, onQtyChange, onMemberQtyChange, onRemove, maxByProductId, variant, }: CartGroupLineRowProps): React.JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* CartGroupLineRow — one cart line that stands for several products.
|
|
5
|
+
*
|
|
6
|
+
* Two shapes behind one component, because the difference is exactly the
|
|
7
|
+
* bundle-vs-group distinction and putting them in separate files would let it
|
|
8
|
+
* drift:
|
|
9
|
+
*
|
|
10
|
+
* BUNDLE — all-or-nothing. Members render read-only; the only stepper is the
|
|
11
|
+
* line's own and it means COPIES OF THE BUNDLE.
|
|
12
|
+
* GROUP — pick-as-you-wish. One stepper PER MEMBER, and no line-level
|
|
13
|
+
* stepper at all (a group line's `quantity` is pinned to 1, so a
|
|
14
|
+
* second multiplier would double-count).
|
|
15
|
+
*
|
|
16
|
+
* Never render both steppers. That is the invariant on `CartLineMember`
|
|
17
|
+
* expressed in UI.
|
|
18
|
+
*/
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import { Button, Div, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
21
|
+
import { QuantityStepper } from "../../../ui/components/QuantityStepper";
|
|
22
|
+
import { MediaImage } from "../../media/MediaImage";
|
|
23
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
24
|
+
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
25
|
+
const __O = { hidden: "overflow-hidden" };
|
|
26
|
+
export function CartGroupLineRow({ id, title, image, currency = "INR", lineKind, members, quantity, href, locked = false, onQtyChange, onMemberQtyChange, onRemove, maxByProductId, variant = "card", }) {
|
|
27
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
28
|
+
const isRow = variant === "row";
|
|
29
|
+
const onePrice = members.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0);
|
|
30
|
+
const lineTotal = onePrice * quantity;
|
|
31
|
+
const unitCount = members.reduce((sum, m) => sum + m.quantity, 0);
|
|
32
|
+
return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: "min-w-0", children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: image && _jsx(MediaImage, { src: image, alt: title, size: "thumbnail" }) }), _jsxs(Stack, { justify: "between", gap: "xs", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", align: "start", className: "min-w-0", children: [href ? (_jsx(TextLink, { href: href, weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: title })), _jsx(Span, { padding: "pill-2xs", surface: "subtle", color: "muted", rounded: "default", className: "flex-shrink-0 text-[10px] tracking-wide", transform: "uppercase", children: lineKind === "bundle" ? "Bundle" : "Group" })] }), _jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsOpen((v) => !v), "aria-expanded": isOpen, className: "self-start text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-primary)] hover:underline", children: [isOpen ? "▼" : "▶", " ", members.length, " item", members.length === 1 ? "" : "s", unitCount !== members.length ? ` · ${unitCount} units` : ""] }), isOpen && (_jsx(Stack, { gap: "xs", className: "w-full", children: members.map((m) => (_jsxs(Row, { gap: "sm", align: "center", justify: "between", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", className: "min-w-0 flex-1", children: [_jsx(Div, { className: `h-10 w-10 flex-shrink-0 ${__O.hidden}`, rounded: "default", surface: "muted", children: m.image && _jsx(MediaImage, { src: m.image, alt: m.title, size: "thumbnail" }) }), _jsxs(Stack, { gap: "none", className: "min-w-0", children: [_jsx(Text, { size: "sm", truncate: 1, color: "primary", children: m.title }), _jsxs(Text, { size: "xs", color: "muted", children: [formatCurrency(m.unitPrice, currency), lineKind === "bundle" ? ` × ${m.quantity}` : ""] })] })] }), lineKind === "group" && onMemberQtyChange && !locked && (_jsxs(Row, { gap: "xs", align: "center", className: "flex-shrink-0", children: [_jsx(QuantityStepper, { value: m.quantity, onChange: (next) => onMemberQtyChange(id, m.productId, next), min: 0, max: maxByProductId?.[m.productId], ariaLabel: `Quantity for ${m.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-group-member"].ariaLabel, onClick: () => onMemberQtyChange(id, m.productId, 0), className: "text-[var(--appkit-color-text-faint)] transition hover:text-error", children: "\u2715" })] })), lineKind === "bundle" && (_jsxs(Span, { size: "xs", color: "muted", className: "flex-shrink-0", children: ["\u00D7 ", m.quantity] }))] }, m.productId))) })), _jsxs(Row, { justify: "between", gap: "sm", className: "min-w-0", children: [_jsx(Text, { weight: "semibold", className: "text-[var(--appkit-color-text)]", children: formatCurrency(lineTotal, currency) }), lineKind === "bundle" && onQtyChange && !locked && (_jsx(QuantityStepper, { value: quantity, onChange: (next) => onQtyChange(id, next), min: 1, ariaLabel: `Number of copies of ${title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel }))] })] }), onRemove && !locked && (_jsx(Button, { onClick: () => onRemove(id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: "self-start text-[var(--appkit-color-text-faint)] transition hover:text-error", children: "\u2715" }))] }));
|
|
33
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { CartItemRow, CartDrawer } from "./CartDrawer";
|
|
2
|
+
export { CartGroupLineRow } from "./CartGroupLineRow";
|
|
3
|
+
export type { CartGroupLineRowProps } from "./CartGroupLineRow";
|
|
2
4
|
export { CartView } from "./CartView";
|
|
3
5
|
export type { CartViewProps, CartOrderGroup } from "./CartView";
|
|
4
6
|
export { CartSummary } from "./CartSummary";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type DocumentReference } from "firebase-admin/firestore";
|
|
2
2
|
import type { DocumentSnapshot } from "../../../providers/db-firebase";
|
|
3
3
|
import { BaseRepository, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
4
|
-
import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type UpdateCartItemInput } from "../schemas";
|
|
4
|
+
import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type AddGroupLineInput, type UpdateCartItemInput, type UpdateCartGroupMembersInput } from "../schemas";
|
|
5
5
|
export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
6
6
|
private static readonly SIEVE_FIELDS;
|
|
7
7
|
constructor();
|
|
@@ -12,6 +12,38 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
|
12
12
|
getOrCreate(userId: string): Promise<CartDocument>;
|
|
13
13
|
createEmpty(userId: string): Promise<CartDocument>;
|
|
14
14
|
addItem(userId: string, input: AddToCartInput): Promise<CartDocument>;
|
|
15
|
+
/**
|
|
16
|
+
* Price of ONE COPY of a multi-member line.
|
|
17
|
+
*
|
|
18
|
+
* Private and called at every write of `groupMembers`, so `item.price` cannot
|
|
19
|
+
* drift from the members it is supposed to summarise — no caller sets it, so
|
|
20
|
+
* no caller can forget it. That property is load-bearing well beyond this
|
|
21
|
+
* file: `getSubtotal`, the cart page's per-seller subtotal, and both lane
|
|
22
|
+
* subtotals all compute `price × quantity`, and a grouped line pins
|
|
23
|
+
* `quantity` to 1, so all four stay correct with no changes of their own.
|
|
24
|
+
*/
|
|
25
|
+
private deriveLinePrice;
|
|
26
|
+
/**
|
|
27
|
+
* Create a multi-member line. Deliberately never merges into an existing
|
|
28
|
+
* line — see the note in `addItem`.
|
|
29
|
+
*/
|
|
30
|
+
addGroupLine(userId: string, input: AddGroupLineInput): Promise<CartDocument>;
|
|
31
|
+
/**
|
|
32
|
+
* Replace a grouped line's member quantities.
|
|
33
|
+
*
|
|
34
|
+
* Takes the WHOLE array rather than a per-member delta: the cart is one
|
|
35
|
+
* Firestore document written with `set()`, so N per-member writes would be
|
|
36
|
+
* both a Rule #6 cost and a genuine lost-update race on a fast double-click.
|
|
37
|
+
*
|
|
38
|
+
* A member dropped to 0 is removed, and **a line left with no members is
|
|
39
|
+
* removed entirely** — enforced here rather than in the client, so a caller
|
|
40
|
+
* that forgets cannot leave an empty ghost line in the document.
|
|
41
|
+
*
|
|
42
|
+
* Only quantities move. Prices, titles and images are the snapshots taken
|
|
43
|
+
* server-side at add time and are never re-accepted from a caller; adding a
|
|
44
|
+
* NEW member is an add-to-cart, which has its own eligibility checks.
|
|
45
|
+
*/
|
|
46
|
+
updateGroupMembers(userId: string, itemId: string, input: UpdateCartGroupMembersInput): Promise<CartDocument>;
|
|
15
47
|
updateItem(userId: string, itemId: string, input: UpdateCartItemInput): Promise<CartDocument>;
|
|
16
48
|
removeItem(userId: string, itemId: string): Promise<CartDocument>;
|
|
17
49
|
clearCart(userId: string): Promise<CartDocument>;
|