@mohasinac/appkit 4.11.3 → 4.12.1
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/auctions/service.d.ts +7 -1
- package/dist/_internal/server/features/auctions/service.js +13 -5
- package/dist/_internal/server/features/checkout/actions.d.ts +36 -18
- package/dist/_internal/server/features/checkout/actions.js +127 -45
- package/dist/_internal/server/features/orders/actions.js +5 -3
- package/dist/_internal/server/features/orders/adapters.js +13 -0
- package/dist/_internal/server/features/products/list-public.d.ts +28 -0
- package/dist/_internal/server/features/products/list-public.js +92 -0
- package/dist/_internal/server/functions/firestore.d.ts +1 -2
- package/dist/_internal/server/functions/firestore.js +1 -9
- package/dist/_internal/server/jobs/core/auctionSettlement.js +5 -2
- package/dist/_internal/server/jobs/core/index.d.ts +0 -1
- package/dist/_internal/server/jobs/core/index.js +0 -1
- package/dist/_internal/server/jobs/core/offerExpiry.js +33 -1
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -2
- package/dist/_internal/server/jobs/handlers/index.js +1 -2
- package/dist/_internal/shared/checkout/lanes.d.ts +20 -0
- package/dist/_internal/shared/checkout/lanes.js +28 -0
- package/dist/_internal/shared/features/auctions/config.d.ts +34 -0
- package/dist/_internal/shared/features/auctions/config.js +47 -3
- package/dist/_internal/shared/features/checkout/config.d.ts +4 -0
- package/dist/_internal/shared/features/checkout/config.js +8 -0
- package/dist/_internal/shared/fees/calculator.d.ts +34 -2
- package/dist/_internal/shared/fees/calculator.js +45 -1
- package/dist/_internal/shared/listing-types/auction/config.js +1 -1
- package/dist/_internal/shared/listing-types/digital-code/config.js +1 -1
- package/dist/_internal/shared/listing-types/live/config.js +1 -1
- package/dist/_internal/shared/listing-types/pre-order/config.js +1 -1
- package/dist/client.d.ts +7 -1
- package/dist/client.js +4 -1
- package/dist/constants/api-endpoints.d.ts +6 -0
- package/dist/constants/api-endpoints.js +2 -0
- package/dist/features/account/components/NotificationBell.js +1 -1
- package/dist/features/admin/components/AdminAddressesView.js +18 -10
- package/dist/features/admin/components/AdminArtView.d.ts +9 -0
- package/dist/features/admin/components/AdminArtView.js +11 -35
- package/dist/features/admin/components/AdminClassifiedView.d.ts +9 -0
- package/dist/features/admin/components/AdminClassifiedView.js +11 -35
- package/dist/features/admin/components/AdminDigitalCodesView.d.ts +9 -0
- package/dist/features/admin/components/AdminDigitalCodesView.js +11 -42
- package/dist/features/admin/components/AdminLiveView.d.ts +9 -0
- package/dist/features/admin/components/AdminLiveView.js +11 -41
- package/dist/features/admin/components/AdminNotificationsView.js +4 -1
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +14 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +18 -5
- package/dist/features/admin/components/AdminOrdersView.js +15 -1
- package/dist/features/admin/components/AdminPaymentMethodsView.js +15 -10
- package/dist/features/admin/components/AdminSiteConfigGuideView.js +1 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +4 -2
- package/dist/features/admin/components/AdminStickersView.d.ts +9 -0
- package/dist/features/admin/components/AdminStickersView.js +11 -35
- package/dist/features/admin/hooks/useAdminListing.js +7 -2
- package/dist/features/admin/schemas/firestore.d.ts +5 -0
- package/dist/features/admin/schemas/firestore.js +1 -0
- package/dist/features/auctions/actions/bid-actions.js +73 -11
- package/dist/features/auctions/components/AuctionDetailPageView.js +8 -2
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +3 -3
- package/dist/features/auctions/components/PlaceBidFormClient.js +89 -14
- package/dist/features/auctions/schemas/bid-input.d.ts +19 -6
- package/dist/features/auctions/schemas/bid-input.js +19 -7
- package/dist/features/auth/repository/user.repository.js +6 -0
- package/dist/features/cart/components/CartDrawer.d.ts +9 -1
- package/dist/features/cart/components/CartDrawer.js +4 -3
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +61 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +25 -0
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +47 -0
- package/dist/features/cart/components/StoreAddonsPicker.js +21 -0
- package/dist/features/cart/components/index.d.ts +4 -0
- package/dist/features/cart/components/index.js +2 -0
- package/dist/features/cart/repository/cart.repository.d.ts +20 -2
- package/dist/features/cart/repository/cart.repository.js +49 -0
- package/dist/features/cart/schemas/firestore.d.ts +26 -0
- package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
- package/dist/features/filters/FilterFacetSection.js +1 -1
- package/dist/features/homepage/components/WhatsAppCommunitySection.js +1 -1
- package/dist/features/layout/BackToTop.js +1 -1
- package/dist/features/layout/BottomActions.js +38 -5
- package/dist/features/layout/BottomActionsContext.d.ts +12 -0
- package/dist/features/layout/BottomActionsContext.js +5 -0
- package/dist/features/layout/FooterLayout.js +2 -2
- package/dist/features/layout/TitleBarLayout.js +1 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +6 -0
- package/dist/features/layout/hooks/useBottomActions.js +11 -1
- package/dist/features/orders/components/OrderAddonBadges.d.ts +36 -0
- package/dist/features/orders/components/OrderAddonBadges.js +28 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/repository/orders.repository.d.ts +32 -0
- package/dist/features/orders/repository/orders.repository.js +14 -0
- package/dist/features/orders/types/index.d.ts +22 -0
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -3
- package/dist/features/products/components/ArtStickersListView.js +5 -4
- package/dist/features/products/components/MarketplaceBundleCard.js +2 -2
- package/dist/features/products/components/MarketplacePrizeDrawCard.js +2 -2
- package/dist/features/products/components/PrizeDrawCollage.js +1 -1
- package/dist/features/products/components/PrizeDrawItemsEditor.js +1 -1
- package/dist/features/products/components/PrizeDrawsListingView.js +18 -33
- package/dist/features/products/components/ProductDetailPageView.js +1 -1
- package/dist/features/products/components/ProductGrid.js +3 -3
- package/dist/features/products/components/ProductsIndexListing.js +87 -11
- package/dist/features/products/components/ProductsIndexPageView.js +14 -2
- package/dist/features/products/config/listing-type-listing-config.d.ts +31 -0
- package/dist/features/products/config/listing-type-listing-config.js +74 -0
- package/dist/features/products/hooks/useProducts.js +14 -0
- package/dist/features/products/repository/products.repository.d.ts +0 -4
- package/dist/features/products/repository/products.repository.js +0 -15
- package/dist/features/products/types/index.d.ts +15 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +12 -0
- package/dist/features/promotions/repository/coupons.repository.js +12 -0
- package/dist/features/seller/components/SellerOrdersView.js +2 -1
- package/dist/features/seller/components/SellerSidebar.js +1 -1
- package/dist/features/stores/components/StoreArtStickersPageView.d.ts +21 -0
- package/dist/features/stores/components/StoreArtStickersPageView.js +30 -0
- package/dist/features/stores/components/StoreAuctionsPageView.d.ts +4 -1
- package/dist/features/stores/components/StoreAuctionsPageView.js +17 -17
- package/dist/features/stores/components/StoreClassifiedsListing.js +7 -0
- package/dist/features/stores/components/StoreClassifiedsPageView.d.ts +4 -1
- package/dist/features/stores/components/StoreClassifiedsPageView.js +16 -12
- package/dist/features/stores/components/StoreDetailLayoutView.js +4 -3
- package/dist/features/stores/components/StoreDigitalCodesListing.js +5 -0
- package/dist/features/stores/components/StoreDigitalCodesPageView.d.ts +4 -1
- package/dist/features/stores/components/StoreDigitalCodesPageView.js +16 -12
- package/dist/features/stores/components/StoreLiveItemsListing.js +7 -0
- package/dist/features/stores/components/StoreLiveItemsPageView.d.ts +4 -1
- package/dist/features/stores/components/StoreLiveItemsPageView.js +16 -12
- package/dist/features/stores/components/StorePreOrdersPageView.d.ts +4 -1
- package/dist/features/stores/components/StorePreOrdersPageView.js +17 -17
- package/dist/features/stores/components/StorePrizeDrawsPageView.d.ts +4 -8
- package/dist/features/stores/components/StorePrizeDrawsPageView.js +17 -22
- package/dist/features/stores/components/StoreProductsListing.d.ts +7 -1
- package/dist/features/stores/components/StoreProductsListing.js +3 -2
- package/dist/features/stores/components/StoreProductsPageView.d.ts +1 -1
- package/dist/features/stores/components/StoreProductsPageView.js +16 -21
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +875 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +1 -1
- package/dist/jobs.d.ts +1 -1
- package/dist/jobs.js +1 -1
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +5 -0
- package/dist/seed/homepage-sections-seed-data.js +5 -1
- package/dist/seed/reviews-seed-data.js +52 -0
- package/dist/seed/site-settings-seed-data.js +18 -0
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +1 -0
- package/dist/styles.css +66 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/color-pairs.js +1 -0
- package/dist/tokens/tokens.css +33 -0
- package/dist/ui/components/Button.style.css +21 -0
- package/dist/ui/components/Checkbox.d.ts +10 -1
- package/dist/ui/components/Checkbox.js +14 -2
- package/dist/ui/components/Checkbox.style.css +11 -0
- package/dist/ui/components/ImageLightbox.js +7 -4
- package/dist/ui/components/surface-tokens.d.ts +7 -0
- package/dist/ui/components/surface-tokens.js +7 -0
- package/package.json +1 -1
- package/dist/_internal/server/jobs/core/onBidPlaced.d.ts +0 -15
- package/dist/_internal/server/jobs/core/onBidPlaced.js +0 -63
- package/dist/_internal/server/jobs/handlers/onBidPlaced.d.ts +0 -3
- package/dist/_internal/server/jobs/handlers/onBidPlaced.js +0 -9
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useTransition } from "react";
|
|
3
|
+
import { useEffect, useRef, useState, useTransition } from "react";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
5
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
6
6
|
import { isAuthError } from "../../../utils/auth-error";
|
|
@@ -10,7 +10,7 @@ import { FieldInput } from "../../../ui/forms/FieldInput";
|
|
|
10
10
|
import { applyZodIssues } from "../../../ui/forms/FormShell";
|
|
11
11
|
import { placeBidSchema } from "../schemas/bid-input";
|
|
12
12
|
import { useLiveAuctionBid } from "../hooks/useLiveAuctionBid";
|
|
13
|
-
import { resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
|
|
13
|
+
import { BID_PRESET_MULTIPLIERS, resolveMinBid, resolveMinBidIncrement, } from "../../../_internal/shared/features/auctions/config";
|
|
14
14
|
import { normalizeError } from "../../../errors/normalize";
|
|
15
15
|
const __P = {
|
|
16
16
|
p5: "p-[var(--appkit-space-5)]",
|
|
@@ -32,19 +32,73 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
32
32
|
// Tiered, floor-raising: resolves fresh whenever the live current bid
|
|
33
33
|
// crosses a tier boundary while this modal is open.
|
|
34
34
|
const minBidIncrement = resolveMinBidIncrement(currentBid, tiers, minBidIncrementOverride);
|
|
35
|
-
|
|
35
|
+
// Mirrors `placeBid`'s own `hasBids`. The `currentBid` prop already falls
|
|
36
|
+
// back to `startingBid` when the auction has no bids, so it can't be tested
|
|
37
|
+
// for `> 0` the way the server tests the raw document field — `bidCount`, or
|
|
38
|
+
// a price that has moved past the opening one, is the equivalent signal.
|
|
39
|
+
const hasBids = bidCount > 0 || currentBid > startingBid;
|
|
40
|
+
// Same helper `placeBid` uses server-side, so the seeded amount below can
|
|
41
|
+
// never be one the server rejects.
|
|
42
|
+
const minBid = resolveMinBid(currentBid, tiers, minBidIncrementOverride, { hasBids });
|
|
36
43
|
const [bidAmount, setBidAmount] = useState(String(minBid));
|
|
37
|
-
const [stepMul, setStepMul] = useState(
|
|
44
|
+
const [stepMul, setStepMul] = useState(BID_PRESET_MULTIPLIERS[0]);
|
|
38
45
|
const [isPending, startTransition] = useTransition();
|
|
39
46
|
const [isBuyNowPending, startBuyNowTransition] = useTransition();
|
|
40
47
|
const [success, setSuccess] = useState(false);
|
|
41
48
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
49
|
+
/** Last amount the form itself wrote — see `seedAmount` below. */
|
|
50
|
+
const lastSeeded = useRef(minBid);
|
|
51
|
+
/**
|
|
52
|
+
* The bid a given preset multiplier resolves to at the live price.
|
|
53
|
+
*
|
|
54
|
+
* Anchored on `minBid` rather than `currentBid` so the first preset is always
|
|
55
|
+
* exactly the minimum acceptable bid. With bids present that is identical to
|
|
56
|
+
* the old `currentBid + increment * n`; with none it makes the opening preset
|
|
57
|
+
* the starting bid itself instead of an amount the seller never asked for.
|
|
58
|
+
*/
|
|
59
|
+
function presetAmount(n) {
|
|
60
|
+
return minBid + minBidIncrement * (n - 1);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Write an amount the FORM chose (not the buyer). Tracking these separately
|
|
64
|
+
* from buyer keystrokes is what lets the live-price effect below tell "this
|
|
65
|
+
* value is still ours to update" from "the buyer typed this".
|
|
66
|
+
*/
|
|
67
|
+
function seedAmount(value) {
|
|
68
|
+
lastSeeded.current = value;
|
|
69
|
+
setBidAmount(String(value));
|
|
70
|
+
}
|
|
42
71
|
function applyPreset(n) {
|
|
43
72
|
setStepMul(n);
|
|
44
|
-
|
|
73
|
+
seedAmount(presetAmount(n));
|
|
45
74
|
}
|
|
75
|
+
// Re-seed the amount whenever the live price moves under a preset selection.
|
|
76
|
+
//
|
|
77
|
+
// `bidAmount` was previously only ever written on mount or on click, but
|
|
78
|
+
// `currentBid` keeps updating from the `/auction-bids/{id}` SSE channel while
|
|
79
|
+
// this form is open. A buyer who opened the modal, sat on it while another
|
|
80
|
+
// bidder moved the price, then hit "Place Bid" was submitting an amount now
|
|
81
|
+
// below the minimum — a guaranteed BID_AMOUNT_TOO_LOW rejection, with the
|
|
82
|
+
// preset buttons above already relabelled to the new tier so the number in
|
|
83
|
+
// the field visibly disagreed with the button that was highlighted.
|
|
84
|
+
//
|
|
85
|
+
// Custom is deliberately left alone: a typed amount is the buyer's own, and
|
|
86
|
+
// the schema + `min` attribute already surface it if the price outran them.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (stepMul === "custom")
|
|
89
|
+
return;
|
|
90
|
+
const next = presetAmount(stepMul);
|
|
91
|
+
if (String(next) === bidAmount)
|
|
92
|
+
return;
|
|
93
|
+
// Only follow the live price, never fight the buyer's own edits.
|
|
94
|
+
if (bidAmount !== String(lastSeeded.current))
|
|
95
|
+
return;
|
|
96
|
+
seedAmount(next);
|
|
97
|
+
// presetAmount is derived from currentBid/minBidIncrement, both in deps.
|
|
98
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
99
|
+
}, [currentBid, minBidIncrement, stepMul]);
|
|
46
100
|
const buyNowAvailable = buyNowPrice !== null && !isEnded && !bidsHaveStarted && !!onBuyNow;
|
|
47
|
-
const schema = placeBidSchema(minBid,
|
|
101
|
+
const schema = placeBidSchema(minBid, formatCurrency(minBid, currency));
|
|
48
102
|
async function submitBid(amount, setFieldError) {
|
|
49
103
|
try {
|
|
50
104
|
const result = await onPlaceBid({ productId, bidAmount: amount });
|
|
@@ -54,8 +108,15 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
54
108
|
return;
|
|
55
109
|
}
|
|
56
110
|
setSuccess(true);
|
|
57
|
-
|
|
58
|
-
|
|
111
|
+
// Provisional next-bid seed, corrected by the live-price effect above as
|
|
112
|
+
// soon as the SSE tick for this bid lands. It is only provisional because
|
|
113
|
+
// under proxy-bid semantics `placeBid` treats the submission as a MAXIMUM
|
|
114
|
+
// — the resulting visible price is usually lower than `amount`, and if the
|
|
115
|
+
// bid lost to a higher standing proxy cap it is higher. The old
|
|
116
|
+
// `amount + increment` was therefore rarely the next valid bid; taking the
|
|
117
|
+
// max against the live price keeps this from ever seeding below minimum.
|
|
118
|
+
setStepMul(BID_PRESET_MULTIPLIERS[0]);
|
|
119
|
+
seedAmount(resolveMinBid(Math.max(currentBid, amount), tiers, minBidIncrementOverride));
|
|
59
120
|
// The bid history list and any duplicated static current-bid/bid-count
|
|
60
121
|
// text elsewhere on this SSR'd page (info panel, page-level summary
|
|
61
122
|
// cards) are frozen server props — refresh so they reflect this bid
|
|
@@ -114,16 +175,30 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
114
175
|
}
|
|
115
176
|
});
|
|
116
177
|
}
|
|
117
|
-
return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: "Current bid" }), _jsx(Text, { size: "xs", color: "muted", children: "Starting bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(startingBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { onSubmit: (e) => {
|
|
178
|
+
return (_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Current bid" : "Starting bid" }), _jsx(Text, { size: "xs", color: "muted", children: hasBids ? "Starting bid" : "Minimum bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { size: "xl", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { size: "sm", color: "muted", children: formatCurrency(hasBids ? startingBid : minBid, currency) })] }), _jsxs(Text, { size: "xs", color: "faint", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx(Form, { onSubmit: (e) => {
|
|
118
179
|
// handler wired through render-prop below — keeps setFieldError in scope
|
|
119
180
|
e.preventDefault();
|
|
120
|
-
}, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
}, children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { gap: "xs", wrap: true, role: "radiogroup", "aria-label": "Bid amount preset", children: [BID_PRESET_MULTIPLIERS.map((n) => {
|
|
182
|
+
const amount = presetAmount(n);
|
|
183
|
+
const delta = amount - currentBid;
|
|
184
|
+
// `delta` is 0 only for the opening preset on a bid-free
|
|
185
|
+
// auction, where the minimum IS the starting bid — "+₹0" would
|
|
186
|
+
// read as a broken button, so name what it actually is.
|
|
187
|
+
const stepLabel = delta > 0 ? `+${formatCurrency(delta, currency)}` : "Minimum";
|
|
188
|
+
return (_jsx(Button, { type: "button", variant: stepMul === n ? "primary" : "secondary", size: "sm", role: "radio", "aria-checked": stepMul === n, disabled: isEnded || isPending, "aria-label": delta > 0
|
|
189
|
+
? `Bid ${formatCurrency(amount, currency)} — ${formatCurrency(delta, currency)} above the current bid`
|
|
190
|
+
: `Bid ${formatCurrency(amount, currency)} — the minimum accepted bid`, onClick: () => {
|
|
191
|
+
clearErrors();
|
|
192
|
+
applyPreset(n);
|
|
193
|
+
}, children: _jsxs(Stack, { gap: "none", align: "center", children: [_jsx(Span, { size: "xs", weight: "semibold", children: stepLabel }), _jsx(Span, { size: "xs", color: "muted", children: formatCurrency(amount, currency) })] }) }, n));
|
|
194
|
+
}), _jsx(Button, { type: "button", variant: stepMul === "custom" ? "primary" : "ghost", size: "sm", role: "radio", "aria-checked": stepMul === "custom", disabled: isEnded || isPending, onClick: () => setStepMul("custom"), children: "Custom" })] }), _jsx(FieldInput, { name: "bidAmount", type: "number", value: bidAmount, readOnly: stepMul !== "custom", onChange: (value) => {
|
|
124
195
|
setBidAmount(value);
|
|
125
196
|
clearErrors();
|
|
126
|
-
}, placeholder: `At least ${formatCurrency(minBid, currency)}`, min: minBid, "aria-label": "Your bid amount", disabled: isEnded || isPending }),
|
|
197
|
+
}, placeholder: `At least ${formatCurrency(minBid, currency)}`, min: minBid, "aria-label": "Your bid amount", disabled: isEnded || isPending }), _jsx(Text, { size: "xs", color: "muted", "aria-live": "polite", children: stepMul === "custom"
|
|
198
|
+
? `Any amount from ${formatCurrency(minBid, currency)} up is accepted — it need not be an exact multiple of the increment.`
|
|
199
|
+
: hasBids
|
|
200
|
+
? `Minimum next bid ${formatCurrency(minBid, currency)} (current bid + ${formatCurrency(minBidIncrement, currency)} increment).`
|
|
201
|
+
: `Minimum opening bid ${formatCurrency(minBid, currency)} — the seller's starting bid. The ${formatCurrency(minBidIncrement, currency)} increment applies from the second bid on.` }), success && (_jsx(Text, { className: "text-success", size: "xs", children: "\u2713 Bid placed successfully!" })), !isEnded && auctionEndDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: auctionEndDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded || isPending, isLoading: isPending, type: "button", onClick: () => {
|
|
127
202
|
clearErrors();
|
|
128
203
|
const parsed = schema.safeParse({ bidAmount });
|
|
129
204
|
if (!parsed.success)
|
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
/**
|
|
3
3
|
* Client-side schema for the place-bid form. Used by PlaceBidFormClient via
|
|
4
|
-
* <Form> + FieldInput.
|
|
5
|
-
* Zod custom-message map.
|
|
4
|
+
* <Form> + FieldInput.
|
|
6
5
|
*
|
|
7
|
-
* `minBid`
|
|
8
|
-
*
|
|
6
|
+
* `minBid` is the auction's live minimum acceptable next bid, resolved by
|
|
7
|
+
* `resolveMinBid()` (`_internal/shared/features/auctions/config`) — the same
|
|
8
|
+
* helper `placeBid` uses server-side, so this form can never seed or accept an
|
|
9
|
+
* amount the server will then reject.
|
|
10
|
+
*
|
|
11
|
+
* `minBidLabel` is the currency-formatted rendering of `minBid` (e.g. "₹1,100")
|
|
12
|
+
* — the schema takes it pre-formatted rather than importing a formatter so the
|
|
13
|
+
* inline error reads in the buyer's currency instead of a bare number.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately NOT enforced here: that the bid is an exact multiple of the
|
|
16
|
+
* increment. `placeBid` only requires `amount >= currentBid + increment`, so
|
|
17
|
+
* adding a multiple-of rule client-side would reject bids the server accepts.
|
|
18
|
+
* A refinement claiming to do that used to exist but was a no-op — its
|
|
19
|
+
* predicate was `(v - minBid) % inc === 0 || (v - minBid) >= 0`, and the
|
|
20
|
+
* right-hand disjunct is always true once the `v >= minBid` check above has
|
|
21
|
+
* passed, so it could never fail.
|
|
9
22
|
*/
|
|
10
|
-
export declare const placeBidSchema: (minBid: number,
|
|
11
|
-
bidAmount: z.ZodEffects<z.ZodEffects<z.
|
|
23
|
+
export declare const placeBidSchema: (minBid: number, minBidLabel: string) => z.ZodObject<{
|
|
24
|
+
bidAmount: z.ZodEffects<z.ZodEffects<z.ZodNumber, number, number>, number, number>;
|
|
12
25
|
}, "strip", z.ZodTypeAny, {
|
|
13
26
|
bidAmount: number;
|
|
14
27
|
}, {
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
/**
|
|
3
3
|
* Client-side schema for the place-bid form. Used by PlaceBidFormClient via
|
|
4
|
-
* <Form> + FieldInput.
|
|
5
|
-
* Zod custom-message map.
|
|
4
|
+
* <Form> + FieldInput.
|
|
6
5
|
*
|
|
7
|
-
* `minBid`
|
|
8
|
-
*
|
|
6
|
+
* `minBid` is the auction's live minimum acceptable next bid, resolved by
|
|
7
|
+
* `resolveMinBid()` (`_internal/shared/features/auctions/config`) — the same
|
|
8
|
+
* helper `placeBid` uses server-side, so this form can never seed or accept an
|
|
9
|
+
* amount the server will then reject.
|
|
10
|
+
*
|
|
11
|
+
* `minBidLabel` is the currency-formatted rendering of `minBid` (e.g. "₹1,100")
|
|
12
|
+
* — the schema takes it pre-formatted rather than importing a formatter so the
|
|
13
|
+
* inline error reads in the buyer's currency instead of a bare number.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately NOT enforced here: that the bid is an exact multiple of the
|
|
16
|
+
* increment. `placeBid` only requires `amount >= currentBid + increment`, so
|
|
17
|
+
* adding a multiple-of rule client-side would reject bids the server accepts.
|
|
18
|
+
* A refinement claiming to do that used to exist but was a no-op — its
|
|
19
|
+
* predicate was `(v - minBid) % inc === 0 || (v - minBid) >= 0`, and the
|
|
20
|
+
* right-hand disjunct is always true once the `v >= minBid` check above has
|
|
21
|
+
* passed, so it could never fail.
|
|
9
22
|
*/
|
|
10
|
-
export const placeBidSchema = (minBid,
|
|
23
|
+
export const placeBidSchema = (minBid, minBidLabel) => z.object({
|
|
11
24
|
bidAmount: z.coerce
|
|
12
25
|
.number({ required_error: "Enter a bid amount" })
|
|
13
26
|
.refine((v) => Number.isFinite(v) && v > 0, "Enter a valid bid amount")
|
|
14
|
-
.refine((v) => v >= minBid, `Bid must be at least ${
|
|
15
|
-
.refine((v) => (v - minBid) % minBidIncrement === 0 || (v - minBid) >= 0, `Bid must respect the minimum increment of ${minBidIncrement}`),
|
|
27
|
+
.refine((v) => v >= minBid, `Bid must be at least ${minBidLabel}`),
|
|
16
28
|
});
|
|
@@ -257,6 +257,12 @@ UserRepository.SIEVE_FIELDS = {
|
|
|
257
257
|
emailVerified: { canFilter: true, canSort: false },
|
|
258
258
|
disabled: { canFilter: true, canSort: true },
|
|
259
259
|
storeStatus: { canFilter: true, canSort: false },
|
|
260
|
+
// AdminTeamView's permission-group filter emits `permissionGroup==X` and
|
|
261
|
+
// /api/admin/team merges it into `role==employee,permissionGroup==X`. Without
|
|
262
|
+
// this entry Sieve (throwExceptions:false) dropped the clause silently and the
|
|
263
|
+
// filter matched every employee. The composite indexes it needs are already
|
|
264
|
+
// declared and deployed — (permissionGroup, role, createdAt|displayName).
|
|
265
|
+
permissionGroup: { canFilter: true, canSort: false },
|
|
260
266
|
createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
261
267
|
updatedAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
262
268
|
};
|
|
@@ -7,8 +7,16 @@ interface CartItemRowProps {
|
|
|
7
7
|
href?: string;
|
|
8
8
|
/** When true: grays out item, shows "Out of Stock" badge, locks qty stepper */
|
|
9
9
|
isOutOfStock?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* `"card"` (default) — self-contained card with its own surface + padding,
|
|
12
|
+
* for the cart drawer where each line stands alone.
|
|
13
|
+
* `"row"` — bare row for the cart page, where the seller group is already the
|
|
14
|
+
* card. Nesting a card inside a card there produced the card-in-a-card look
|
|
15
|
+
* and gave the overflow nowhere to be clipped.
|
|
16
|
+
*/
|
|
17
|
+
variant?: "card" | "row";
|
|
10
18
|
}
|
|
11
|
-
export declare function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock }: CartItemRowProps): import("react").JSX.Element;
|
|
19
|
+
export declare function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock, variant }: CartItemRowProps): import("react").JSX.Element;
|
|
12
20
|
interface CartDrawerProps {
|
|
13
21
|
isOpen: boolean;
|
|
14
22
|
onClose: () => void;
|
|
@@ -13,11 +13,12 @@ const __O = {
|
|
|
13
13
|
yAuto: "overflow-y-auto",
|
|
14
14
|
};
|
|
15
15
|
const CLS_REMOVE_BTN = "self-start text-[var(--appkit-color-text-faint)] transition hover:text-error";
|
|
16
|
-
export function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock = false }) {
|
|
17
|
-
|
|
16
|
+
export function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock = false, variant = "card" }) {
|
|
17
|
+
const isRow = variant === "row";
|
|
18
|
+
return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: `min-w-0 transition-opacity ${isOutOfStock ? "opacity-60" : ""}`, children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: item.meta.image && (_jsx(MediaImage, { src: item.meta.image, alt: item.meta.title, size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", className: "min-w-0", align: "start", children: [href ? (_jsx(TextLink, { href: href, target: "_blank", rel: "noopener noreferrer", weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: item.meta.title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.meta.title })), isOutOfStock && (_jsx(Span, { padding: "pill-2xs", color: "error", surface: "danger-surface", weight: "semibold", className: "flex-shrink-0 text-[10px] tracking-wide", rounded: "default", transform: "uppercase", children: "Out of Stock" }))] }), item.meta.attributes &&
|
|
18
19
|
Object.keys(item.meta.attributes).length > 0 && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", truncate: 1, children: Object.entries(item.meta.attributes)
|
|
19
20
|
.map(([k, v]) => `${k}: ${v}`)
|
|
20
|
-
.join(", ") })), _jsxs(Row, { justify: "between", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity - 1), disabled: item.quantity <= 1, variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] disabled:opacity-40", children: "\u2212" }), _jsx(Span, { size: "sm", className: "min-w-[1.5rem]", align: "center", children: item.quantity }), _jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity + 1), variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)]", children: "+" })] })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
|
|
21
|
+
.join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity - 1), disabled: item.quantity <= 1, variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] disabled:opacity-40", children: "\u2212" }), _jsx(Span, { size: "sm", className: "min-w-[1.5rem]", align: "center", children: item.quantity }), _jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity + 1), variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)]", children: "+" })] })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
|
|
21
22
|
}
|
|
22
23
|
export function CartDrawer({ isOpen, onClose, items, subtotal = 0, currency, isLoading, onQtyChange, onRemove, onCheckout, labels = {}, }) {
|
|
23
24
|
const { hand } = useHandMode();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The aggregate price breakdown shown in the cart's expandable preview and in
|
|
4
|
+
* the checkout Order Summary.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately flat: one line per fee type, with a `× N` qualifier on the fees
|
|
7
|
+
* that accumulate per store. The per-store detail lives on each seller card in
|
|
8
|
+
* the cart, where the store's items and its add-on checkboxes already are —
|
|
9
|
+
* repeating it here would make the panel a second, competing place to read the
|
|
10
|
+
* same numbers.
|
|
11
|
+
*
|
|
12
|
+
* Every figure comes from the server's pricing preview, which prices only the
|
|
13
|
+
* items actually selected for checkout. A deselected item contributes to no
|
|
14
|
+
* line, and a store with nothing selected forms no order group at all, so it
|
|
15
|
+
* contributes no shipping and no add-ons.
|
|
16
|
+
*/
|
|
17
|
+
/** Mirrors `CheckoutPricingPreviewStore` without importing server code. */
|
|
18
|
+
export interface CartPriceBreakdownStore {
|
|
19
|
+
storeId: string;
|
|
20
|
+
storeName: string;
|
|
21
|
+
subtotal: number;
|
|
22
|
+
shippingFee: number;
|
|
23
|
+
codHandlingFee: number;
|
|
24
|
+
whatsappNotifyFee: number;
|
|
25
|
+
giftWrapFee: number;
|
|
26
|
+
shipmentProtectionFee: number;
|
|
27
|
+
gstAmount: number;
|
|
28
|
+
couponDiscount: number;
|
|
29
|
+
total: number;
|
|
30
|
+
}
|
|
31
|
+
/** Mirrors `CheckoutPricingPreview` without importing server code. */
|
|
32
|
+
export interface CartPriceBreakdownData {
|
|
33
|
+
subtotal: number;
|
|
34
|
+
shippingFee: number;
|
|
35
|
+
codHandlingFee: number;
|
|
36
|
+
whatsappNotifyFee: number;
|
|
37
|
+
giftWrapFee: number;
|
|
38
|
+
shipmentProtectionFee: number;
|
|
39
|
+
gstAmount: number;
|
|
40
|
+
couponDiscount: number;
|
|
41
|
+
platformFee: number;
|
|
42
|
+
gstOnFee: number;
|
|
43
|
+
total: number;
|
|
44
|
+
stores: CartPriceBreakdownStore[];
|
|
45
|
+
}
|
|
46
|
+
export interface CartPriceBreakdownProps {
|
|
47
|
+
preview: CartPriceBreakdownData | null;
|
|
48
|
+
/** Number of items included — shown in the subtotal line so a deselection is visible. */
|
|
49
|
+
itemCount?: number;
|
|
50
|
+
currency?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Shown instead of the fee lines when no server preview is available (a
|
|
53
|
+
* signed-out cart). The subtotal still renders from whatever is known.
|
|
54
|
+
*/
|
|
55
|
+
unavailableNote?: React.ReactNode;
|
|
56
|
+
/** Subtotal to show when `preview` is null (guest carts). */
|
|
57
|
+
fallbackSubtotal?: number;
|
|
58
|
+
isLoading?: boolean;
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
export declare function CartPriceBreakdown({ preview, itemCount, currency, unavailableNote, fallbackSubtotal, isLoading, className, }: CartPriceBreakdownProps): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Div, Row, Stack, Text } from "../../../ui";
|
|
4
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
5
|
+
function Line({ label, amount, tone = "muted", strong = false, }) {
|
|
6
|
+
return (_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: tone, weight: strong ? "semibold" : undefined, className: "min-w-0", truncate: 1, children: label }), _jsx(Text, { size: "sm", color: tone, weight: strong ? "semibold" : undefined, className: "flex-shrink-0 tabular-nums", children: amount })] }));
|
|
7
|
+
}
|
|
8
|
+
/** "Shipping (2 stores)" — only qualifies when more than one store contributes. */
|
|
9
|
+
function withStoreCount(label, count) {
|
|
10
|
+
return count > 1 ? `${label} (${count} stores)` : label;
|
|
11
|
+
}
|
|
12
|
+
export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unavailableNote, fallbackSubtotal = 0, isLoading = false, className = "", }) {
|
|
13
|
+
const money = (value) => formatCurrency(value, currency);
|
|
14
|
+
if (!preview) {
|
|
15
|
+
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: itemCount !== undefined ? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""})` : "Subtotal", amount: money(fallbackSubtotal) }), unavailableNote && (_jsx(Text, { size: "xs", color: "muted", children: unavailableNote }))] }));
|
|
16
|
+
}
|
|
17
|
+
const storeCount = preview.stores.length;
|
|
18
|
+
// Count how many stores are actually charged each per-store add-on, so the
|
|
19
|
+
// "× N" reads as the real multiplier rather than the store count.
|
|
20
|
+
const countCharged = (pick) => preview.stores.filter((s) => pick(s) > 0).length;
|
|
21
|
+
const subtotalLabel = itemCount !== undefined
|
|
22
|
+
? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
|
|
23
|
+
: "Subtotal";
|
|
24
|
+
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.whatsappNotifyFee > 0 && (_jsx(Line, { label: withStoreCount("WhatsApp updates", countCharged((s) => s.whatsappNotifyFee)), amount: money(preview.whatsappNotifyFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), (preview.gstAmount > 0 || preview.gstOnFee > 0) && (_jsx(Line, { label: "GST", amount: money(preview.gstAmount + preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" }))] }));
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Paid add-on checkboxes for ONE store.
|
|
4
|
+
*
|
|
5
|
+
* Add-on fees are billed per order group (= per store), so they are chosen per
|
|
6
|
+
* store: ticking "WhatsApp updates" for one seller must not bill it for every
|
|
7
|
+
* seller in the cart. Rendered inside that store's card in the cart, and inside
|
|
8
|
+
* that store's row of the checkout summary — Buy Now skips the cart entirely,
|
|
9
|
+
* so both surfaces have to be able to edit them.
|
|
10
|
+
*/
|
|
11
|
+
export interface StoreAddonsValue {
|
|
12
|
+
whatsappNotifyAddon?: boolean;
|
|
13
|
+
giftWrapAddon?: boolean;
|
|
14
|
+
giftWrapMessage?: string;
|
|
15
|
+
shipmentProtectionAddon?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** The subset of `siteSettings.commissions` that gates and prices the add-ons. */
|
|
18
|
+
export interface StoreAddonsRates {
|
|
19
|
+
whatsappNotifyFeeEnabled?: boolean;
|
|
20
|
+
whatsappNotifyFee?: number;
|
|
21
|
+
giftWrapFeeEnabled?: boolean;
|
|
22
|
+
giftWrapFee?: number;
|
|
23
|
+
shipmentProtectionFeeEnabled?: boolean;
|
|
24
|
+
shipmentProtectionFeePercent?: number;
|
|
25
|
+
shipmentProtectionFeeMin?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface StoreAddonsPickerProps {
|
|
28
|
+
storeId: string;
|
|
29
|
+
/** This store's subtotal — shipment protection is a percentage of it. */
|
|
30
|
+
storeSubtotal: number;
|
|
31
|
+
value: StoreAddonsValue;
|
|
32
|
+
onChange: (storeId: string, next: StoreAddonsValue) => void;
|
|
33
|
+
rates: StoreAddonsRates | null;
|
|
34
|
+
/**
|
|
35
|
+
* When true the controls render disabled with `disabledReason`. Used in the
|
|
36
|
+
* cart when none of this store's items are selected — the fees genuinely
|
|
37
|
+
* aren't being charged, and silently hiding the controls would read as the
|
|
38
|
+
* add-ons having vanished.
|
|
39
|
+
*/
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
disabledReason?: string;
|
|
42
|
+
/** Gift messages don't belong in a collapsed cart sheet — checkout only. */
|
|
43
|
+
showGiftMessage?: boolean;
|
|
44
|
+
currency?: string;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function StoreAddonsPicker({ storeId, storeSubtotal, value, onChange, rates, disabled, disabledReason, showGiftMessage, currency, className, }: StoreAddonsPickerProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Gift, MessageCircle, ShieldCheck } from "lucide-react";
|
|
4
|
+
import { Div, Row, Stack, Text } from "../../../ui";
|
|
5
|
+
import { IconBox } from "../../../ui/components/IconBox";
|
|
6
|
+
import { FieldCheckbox } from "../../../ui/forms/FieldCheckbox";
|
|
7
|
+
import { FieldTextarea } from "../../../ui/forms/FieldTextarea";
|
|
8
|
+
import { formatCurrency } from "../../../utils/number.formatter";
|
|
9
|
+
export function StoreAddonsPicker({ storeId, storeSubtotal, value, onChange, rates, disabled = false, disabledReason, showGiftMessage = false, currency = "INR", className = "", }) {
|
|
10
|
+
const anyEnabled = !!rates &&
|
|
11
|
+
(rates.whatsappNotifyFeeEnabled ||
|
|
12
|
+
rates.giftWrapFeeEnabled ||
|
|
13
|
+
rates.shipmentProtectionFeeEnabled);
|
|
14
|
+
if (!anyEnabled)
|
|
15
|
+
return null;
|
|
16
|
+
const money = (value) => formatCurrency(value, currency);
|
|
17
|
+
const set = (patch) => onChange(storeId, { ...value, ...patch });
|
|
18
|
+
// Mirrors computeShipmentProtectionFee so the label states the real charge.
|
|
19
|
+
const protectionFee = Math.max(rates.shipmentProtectionFeeMin ?? 30, Math.round(storeSubtotal * ((rates.shipmentProtectionFeePercent ?? 2) / 100) * 100) / 100);
|
|
20
|
+
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.whatsappNotifyFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(MessageCircle, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `whatsappNotifyAddon-${storeId}`, label: `WhatsApp order updates (+${money(rates.whatsappNotifyFee ?? 10)})`, checked: !!value.whatsappNotifyAddon, onChange: (checked) => set({ whatsappNotifyAddon: checked }), disabled: disabled }) })] })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), rates.shipmentProtectionFeeEnabled && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] }))] }));
|
|
21
|
+
}
|
|
@@ -3,6 +3,10 @@ export { CartView } from "./CartView";
|
|
|
3
3
|
export type { CartViewProps, CartOrderGroup } from "./CartView";
|
|
4
4
|
export { CartSummary } from "./CartSummary";
|
|
5
5
|
export type { CartSummaryProps } from "./CartSummary";
|
|
6
|
+
export { StoreAddonsPicker } from "./StoreAddonsPicker";
|
|
7
|
+
export type { StoreAddonsPickerProps, StoreAddonsValue, StoreAddonsRates, } from "./StoreAddonsPicker";
|
|
8
|
+
export { CartPriceBreakdown } from "./CartPriceBreakdown";
|
|
9
|
+
export type { CartPriceBreakdownProps, CartPriceBreakdownData, CartPriceBreakdownStore, } from "./CartPriceBreakdown";
|
|
6
10
|
export { CheckoutView } from "./CheckoutView";
|
|
7
11
|
export type { CheckoutViewProps } from "./CheckoutView";
|
|
8
12
|
export { CheckoutAddressStep } from "./CheckoutAddressStep";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { CartItemRow, CartDrawer } from "./CartDrawer";
|
|
2
2
|
export { CartView } from "./CartView";
|
|
3
3
|
export { CartSummary } from "./CartSummary";
|
|
4
|
+
export { StoreAddonsPicker } from "./StoreAddonsPicker";
|
|
5
|
+
export { CartPriceBreakdown } from "./CartPriceBreakdown";
|
|
4
6
|
export { CheckoutView } from "./CheckoutView";
|
|
5
7
|
export { CheckoutAddressStep } from "./CheckoutAddressStep";
|
|
6
8
|
export { CheckoutSuccessView } from "./CheckoutSuccessView";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
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 CartItemDocument, type UpdateCartItemInput } from "../schemas";
|
|
4
|
+
import { type AddToCartInput, type CartAppliedCoupon, type CartDocument, type CartStoreAddons, type CartItemDocument, type UpdateCartItemInput } from "../schemas";
|
|
5
5
|
export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
6
6
|
private static readonly SIEVE_FIELDS;
|
|
7
7
|
constructor();
|
|
@@ -45,6 +45,24 @@ export declare class CartRepository extends BaseRepository<CartDocument> {
|
|
|
45
45
|
updateItemShipping(userId: string, itemId: string, providerId: string, fee: number): Promise<CartDocument>;
|
|
46
46
|
clearAllCoupons(userId: string): Promise<void>;
|
|
47
47
|
setSelectedItems(userId: string, itemIds: string[] | null): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Set (or clear) one store's add-on selections.
|
|
50
|
+
*
|
|
51
|
+
* Scoped to a single storeId so two stores' selections can't clobber each
|
|
52
|
+
* other, and written with a dotted field path so a concurrent write to a
|
|
53
|
+
* different store merges rather than overwriting the whole map.
|
|
54
|
+
*
|
|
55
|
+
* Passing an empty selection deletes the entry outright instead of leaving
|
|
56
|
+
* `{}` behind — an all-false record and an absent record mean the same thing,
|
|
57
|
+
* and keeping both shapes around invites readers that handle only one.
|
|
58
|
+
*/
|
|
59
|
+
setStoreAddons(userId: string, storeId: string, addons: CartStoreAddons | null): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Drop a store's add-on entry once its last item leaves the cart, so the map
|
|
62
|
+
* doesn't accumulate orphans. Cosmetic — an orphan is already unreachable,
|
|
63
|
+
* because a store with no items forms no order group.
|
|
64
|
+
*/
|
|
65
|
+
pruneStoreAddons(userId: string, keepStoreIds: string[]): Promise<void>;
|
|
48
66
|
/**
|
|
49
67
|
* Cloud Functions: return refs of stale carts not updated within TTL.
|
|
50
68
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
2
3
|
import { DatabaseError, NotFoundError, ValidationError } from "../../../errors";
|
|
3
4
|
import { CART_FIELDS } from "../../../constants/field-names";
|
|
4
5
|
import { normalizeError } from "../../../errors/normalize";
|
|
@@ -337,6 +338,54 @@ export class CartRepository extends BaseRepository {
|
|
|
337
338
|
.doc(userId)
|
|
338
339
|
.set({ selectedItemIds: itemIds ?? null, updatedAt: new Date() }, { merge: true });
|
|
339
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Set (or clear) one store's add-on selections.
|
|
343
|
+
*
|
|
344
|
+
* Scoped to a single storeId so two stores' selections can't clobber each
|
|
345
|
+
* other, and written with a dotted field path so a concurrent write to a
|
|
346
|
+
* different store merges rather than overwriting the whole map.
|
|
347
|
+
*
|
|
348
|
+
* Passing an empty selection deletes the entry outright instead of leaving
|
|
349
|
+
* `{}` behind — an all-false record and an absent record mean the same thing,
|
|
350
|
+
* and keeping both shapes around invites readers that handle only one.
|
|
351
|
+
*/
|
|
352
|
+
async setStoreAddons(userId, storeId, addons) {
|
|
353
|
+
const isEmpty = !addons ||
|
|
354
|
+
(!addons.whatsappNotifyAddon &&
|
|
355
|
+
!addons.giftWrapAddon &&
|
|
356
|
+
!addons.shipmentProtectionAddon);
|
|
357
|
+
await this.db
|
|
358
|
+
.collection(this.collection)
|
|
359
|
+
.doc(userId)
|
|
360
|
+
.set({
|
|
361
|
+
storeAddons: {
|
|
362
|
+
[storeId]: isEmpty ? FieldValue.delete() : addons,
|
|
363
|
+
},
|
|
364
|
+
updatedAt: new Date(),
|
|
365
|
+
}, { merge: true });
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Drop a store's add-on entry once its last item leaves the cart, so the map
|
|
369
|
+
* doesn't accumulate orphans. Cosmetic — an orphan is already unreachable,
|
|
370
|
+
* because a store with no items forms no order group.
|
|
371
|
+
*/
|
|
372
|
+
async pruneStoreAddons(userId, keepStoreIds) {
|
|
373
|
+
const cart = await this.findByUserId(userId);
|
|
374
|
+
const existing = cart?.storeAddons;
|
|
375
|
+
if (!existing)
|
|
376
|
+
return;
|
|
377
|
+
const keep = new Set(keepStoreIds);
|
|
378
|
+
const stale = Object.keys(existing).filter((storeId) => !keep.has(storeId));
|
|
379
|
+
if (stale.length === 0)
|
|
380
|
+
return;
|
|
381
|
+
await this.db
|
|
382
|
+
.collection(this.collection)
|
|
383
|
+
.doc(userId)
|
|
384
|
+
.set({
|
|
385
|
+
storeAddons: Object.fromEntries(stale.map((storeId) => [storeId, FieldValue.delete()])),
|
|
386
|
+
updatedAt: new Date(),
|
|
387
|
+
}, { merge: true });
|
|
388
|
+
}
|
|
340
389
|
/**
|
|
341
390
|
* Cloud Functions: return refs of stale carts not updated within TTL.
|
|
342
391
|
*/
|
|
@@ -83,6 +83,21 @@ export interface CartAppliedCoupon {
|
|
|
83
83
|
/** Item IDs (CartItemDocument.itemId) this coupon was calculated against */
|
|
84
84
|
applicableItemIds?: string[];
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Paid add-ons the buyer opted into, for ONE store.
|
|
88
|
+
*
|
|
89
|
+
* Add-on fees have always been billed per order group (= per store) by
|
|
90
|
+
* `createOrderForGroup` / `previewCheckoutPricing`, but the selection used to be
|
|
91
|
+
* a single cart-wide boolean — so one tick on "WhatsApp updates" quietly billed
|
|
92
|
+
* ₹10 × every store in the cart, with no way to opt in for just one seller.
|
|
93
|
+
* Keying the selection by storeId makes the choice match the billing.
|
|
94
|
+
*/
|
|
95
|
+
export interface CartStoreAddons {
|
|
96
|
+
whatsappNotifyAddon?: boolean;
|
|
97
|
+
giftWrapAddon?: boolean;
|
|
98
|
+
giftWrapMessage?: string;
|
|
99
|
+
shipmentProtectionAddon?: boolean;
|
|
100
|
+
}
|
|
86
101
|
export interface CartDocument extends BaseDocument {
|
|
87
102
|
userId: string;
|
|
88
103
|
items: CartItemDocument[];
|
|
@@ -90,6 +105,17 @@ export interface CartDocument extends BaseDocument {
|
|
|
90
105
|
appliedCoupons?: CartAppliedCoupon[];
|
|
91
106
|
/** Item IDs the user has selected for the next checkout (undefined = all items) */
|
|
92
107
|
selectedItemIds?: string[];
|
|
108
|
+
/**
|
|
109
|
+
* Per-store add-on selections, keyed by `storeId` — the same key
|
|
110
|
+
* `splitCartIntoOrderGroups` groups on.
|
|
111
|
+
*
|
|
112
|
+
* This map is the source of truth for what gets charged; the checkout request
|
|
113
|
+
* no longer carries addon booleans. A store with no selected items forms no
|
|
114
|
+
* order group, so its entry is simply never read — that is what makes
|
|
115
|
+
* "only charge a store whose items are actually checking out" structural
|
|
116
|
+
* rather than a flag someone has to remember to check.
|
|
117
|
+
*/
|
|
118
|
+
storeAddons?: Record<string, CartStoreAddons>;
|
|
93
119
|
}
|
|
94
120
|
export declare const CART_COLLECTION: "carts";
|
|
95
121
|
export declare const CART_INDEXED_FIELDS: string[];
|