@mohasinac/appkit 4.55.0-next.0 → 4.56.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/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
- package/dist/_internal/server/features/checkout/actions.js +100 -5
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
- package/dist/_internal/shared/features/checkout/config.js +1 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/fees.js +1 -1
- package/dist/_internal/shared/fees/calculator.d.ts +35 -3
- package/dist/_internal/shared/fees/calculator.js +34 -4
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +124 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +8 -0
- package/dist/errors/error-codes.d.ts +15 -0
- package/dist/errors/error-codes.js +15 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/account/components/UserBidsView.js +4 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
- package/dist/features/admin/schemas/firestore.d.ts +37 -2
- package/dist/features/admin/schemas/firestore.js +12 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/checkout/schemas/firestore.d.ts +12 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/homepage/components/HeroCarousel.js +4 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +22 -5
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/layout/NavbarLayout.js +139 -32
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/media/messages/en.json +67 -67
- package/dist/features/orders/schemas/firestore.d.ts +39 -1
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +102 -0
- package/dist/features/points/schemas/firestore.js +99 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +5 -26
- package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
- package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
- package/dist/features/products/components/ProductsIndexListing.js +35 -5
- package/dist/features/products/components/ProductsIndexPageView.js +40 -7
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
- package/dist/providers/shipping-shiprocket/index.js +34 -10
- package/dist/security/csp.d.ts +1 -16
- package/dist/security/csp.js +3 -5
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/seed/faq-seed-data.js +1 -1
- package/dist/seed/site-settings-seed-data.js +1 -1
- package/dist/seo/json-ld.d.ts +8 -1
- package/dist/seo/json-ld.js +39 -2
- package/dist/styles.css +419 -542
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +1 -3
- package/dist/tokens/themes/index.js +1 -11
- package/dist/tokens/tokens.css +0 -155
- package/dist/ui/components/GatedPrice.style.css +43 -43
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/dist/tokens/themes/technical-dark.d.ts +0 -30
- package/dist/tokens/themes/technical-dark.js +0 -103
- package/dist/tokens/themes/technical-light.d.ts +0 -22
- package/dist/tokens/themes/technical-light.js +0 -89
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useRef, useState, useEffect, useCallback } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
4
5
|
import Link from "next/link.js";
|
|
5
6
|
import { ChevronDown } from "lucide-react";
|
|
6
7
|
import { Div, IconButton, Li, Nav, Row, Span, Ul } from "../../ui/index.js";
|
|
@@ -10,6 +11,20 @@ import { useHoldToOpen } from "./useHoldToOpen.js";
|
|
|
10
11
|
const __O = {
|
|
11
12
|
hidden: "overflow-hidden",
|
|
12
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* How long the panel survives the pointer leaving the trigger.
|
|
16
|
+
*
|
|
17
|
+
* It is a hover BRIDGE, and it replaces the `pt` padding the in-flow panel used
|
|
18
|
+
* to carry for the same job: the panel is portalled to `<body>` now, so there is
|
|
19
|
+
* no shared box the pointer can cross and no padding that would help. 150ms is
|
|
20
|
+
* long enough for a diagonal flick from the trigger to the first menu row and
|
|
21
|
+
* short enough that it never reads as a stuck menu.
|
|
22
|
+
*/
|
|
23
|
+
const CLOSE_GRACE_MS = 150;
|
|
24
|
+
/** `min-w-[15rem]` on the panel — the width estimate used before it has mounted. */
|
|
25
|
+
const PANEL_MIN_WIDTH_PX = 240;
|
|
26
|
+
/** Keeps a right-aligned panel off the viewport edge. */
|
|
27
|
+
const VIEWPORT_MARGIN_PX = 8;
|
|
13
28
|
function DefaultNavItem({ item, isActive, }) {
|
|
14
29
|
const activeClasses = item.highlighted
|
|
15
30
|
? "border border-primary-400/40 dark:border-secondary-400/30 text-primary-700 dark:text-secondary-400 bg-primary-50/80 dark:bg-secondary-900/30 px-[var(--appkit-space-3)] transition-colors duration-150"
|
|
@@ -18,21 +33,105 @@ function DefaultNavItem({ item, isActive, }) {
|
|
|
18
33
|
: "text-[var(--appkit-color-text-muted)] hover:bg-surface-hover hover:text-zinc-950 dark:hover:text-white transition-colors duration-150 px-[var(--appkit-space-3)]";
|
|
19
34
|
const hasChildren = Boolean(item.children && item.children.length > 0);
|
|
20
35
|
/*
|
|
21
|
-
*
|
|
36
|
+
* 🛑 OPEN STATE IS JS, AND THE PANEL IS PORTALLED. Neither is a preference.
|
|
37
|
+
*
|
|
38
|
+
* This used to open on `group-hover` / `group-focus-within` with the panel
|
|
39
|
+
* `position: absolute` inside the item — and the panel was INVISIBLE on every
|
|
40
|
+
* surface that renders this navbar, because `NavbarLayout` wraps the items in
|
|
41
|
+
* `relative flex-1 overflow-hidden` and a `overflow-x-auto` scroller for its
|
|
42
|
+
* scroll arrows. An absolutely-positioned descendant cannot escape an
|
|
43
|
+
* ancestor's overflow, and `overflow-x: auto` forces `overflow-y` to `auto`
|
|
44
|
+
* as well, so the 346px panel was clipped to the 40px nav strip.
|
|
45
|
+
*
|
|
46
|
+
* That is also why it was previously measured as working: the computed style
|
|
47
|
+
* really is `display: block` with a 346px height. Clipping does not show up
|
|
48
|
+
* in a computed style — only in what you can see.
|
|
22
49
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
50
|
+
* The only fix is to take the panel out of the clipping ancestor entirely, so
|
|
51
|
+
* it renders into `<body>` as `position: fixed` at coordinates read off the
|
|
52
|
+
* trigger. Hover and focus therefore cannot be CSS any more: there is no
|
|
53
|
+
* shared ancestor left for `:hover` to descend through. All four affordances
|
|
54
|
+
* survive — hover, focusing the trigger, the caret button, and hold-to-open
|
|
55
|
+
* on touch — and what is given up is the pre-hydration open, which was never
|
|
56
|
+
* real here: a clipped panel is not a working no-JS fallback.
|
|
57
|
+
*
|
|
58
|
+
* 🛑 Known limit, deliberately accepted rather than papered over: the panel
|
|
59
|
+
* is at the END of `<body>`, so TABBING off the caret moves to the next nav
|
|
60
|
+
* item (closing the menu) instead of into the children. The children stay
|
|
61
|
+
* reachable because the parent is a real link to a page that lists them all
|
|
62
|
+
* — the same argument the CSS version made — and Escape returns focus to the
|
|
63
|
+
* caret. Closing that properly means roving-tabindex menu semantics, which is
|
|
64
|
+
* its own change and should not ride along with a clipping fix.
|
|
29
65
|
*/
|
|
30
66
|
const [open, setOpen] = useState(false);
|
|
67
|
+
const [coords, setCoords] = useState(null);
|
|
68
|
+
const [mounted, setMounted] = useState(false);
|
|
31
69
|
const wrapRef = useRef(null);
|
|
70
|
+
const panelRef = useRef(null);
|
|
71
|
+
const closeTimerRef = useRef(null);
|
|
32
72
|
const panelId = `nav-panel-${item.href.replace(/[^a-z0-9]+/gi, "-")}`;
|
|
73
|
+
// `createPortal` needs a DOM node, which does not exist during SSR.
|
|
74
|
+
useEffect(() => setMounted(true), []);
|
|
75
|
+
const cancelClose = useCallback(() => {
|
|
76
|
+
if (closeTimerRef.current) {
|
|
77
|
+
clearTimeout(closeTimerRef.current);
|
|
78
|
+
closeTimerRef.current = null;
|
|
79
|
+
}
|
|
80
|
+
}, []);
|
|
81
|
+
const openNow = useCallback(() => {
|
|
82
|
+
cancelClose();
|
|
83
|
+
setOpen(true);
|
|
84
|
+
}, [cancelClose]);
|
|
85
|
+
const scheduleClose = useCallback(() => {
|
|
86
|
+
cancelClose();
|
|
87
|
+
closeTimerRef.current = setTimeout(() => setOpen(false), CLOSE_GRACE_MS);
|
|
88
|
+
}, [cancelClose]);
|
|
89
|
+
// A pending timer outliving the item would call `setOpen` after unmount; the
|
|
90
|
+
// nav re-renders its item list whenever the listing-type flags or the session
|
|
91
|
+
// resolve.
|
|
92
|
+
useEffect(() => cancelClose, [cancelClose]);
|
|
93
|
+
/*
|
|
94
|
+
* Re-read the trigger's position whenever anything can move it.
|
|
95
|
+
*
|
|
96
|
+
* 🛑 The scroll listener is CAPTURING. The nav items live in their own
|
|
97
|
+
* horizontal scroller, so the scroll that slides the trigger sideways is that
|
|
98
|
+
* element's, not the window's — a bubbling listener on `window` never sees
|
|
99
|
+
* it, and the panel would hang over wherever the trigger used to be.
|
|
100
|
+
*/
|
|
101
|
+
const measure = useCallback(() => {
|
|
102
|
+
const el = wrapRef.current;
|
|
103
|
+
if (!el)
|
|
104
|
+
return;
|
|
105
|
+
const rect = el.getBoundingClientRect();
|
|
106
|
+
const width = panelRef.current?.offsetWidth || PANEL_MIN_WIDTH_PX;
|
|
107
|
+
const maxLeft = Math.max(VIEWPORT_MARGIN_PX, window.innerWidth - width - VIEWPORT_MARGIN_PX);
|
|
108
|
+
setCoords({
|
|
109
|
+
top: rect.bottom,
|
|
110
|
+
left: Math.min(Math.max(rect.left, VIEWPORT_MARGIN_PX), maxLeft),
|
|
111
|
+
});
|
|
112
|
+
}, []);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!open) {
|
|
115
|
+
setCoords(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
measure();
|
|
119
|
+
window.addEventListener("resize", measure);
|
|
120
|
+
window.addEventListener("scroll", measure, true);
|
|
121
|
+
return () => {
|
|
122
|
+
window.removeEventListener("resize", measure);
|
|
123
|
+
window.removeEventListener("scroll", measure, true);
|
|
124
|
+
};
|
|
125
|
+
}, [open, measure]);
|
|
33
126
|
/*
|
|
34
127
|
* Close on outside click, on Escape, and whenever the route changes.
|
|
35
128
|
*
|
|
129
|
+
* 🛑 The outside test has to accept the PANEL too. It is portalled, so it is
|
|
130
|
+
* not inside `wrapRef` any more — testing the wrapper alone would treat a
|
|
131
|
+
* click on a menu link as "outside", unmount the link on `mousedown`, and the
|
|
132
|
+
* `click` that follows would land on nothing. Every menu item would silently
|
|
133
|
+
* fail to navigate.
|
|
134
|
+
*
|
|
36
135
|
* The route-change close is the one that is easy to miss and the most
|
|
37
136
|
* annoying to live with: the panel's own links navigate, and without this the
|
|
38
137
|
* menu stays open over the page it just took you to. `isActive` is derived
|
|
@@ -42,12 +141,20 @@ function DefaultNavItem({ item, isActive, }) {
|
|
|
42
141
|
if (!open)
|
|
43
142
|
return;
|
|
44
143
|
const onDown = (e) => {
|
|
45
|
-
|
|
46
|
-
|
|
144
|
+
const target = e.target;
|
|
145
|
+
if (wrapRef.current?.contains(target))
|
|
146
|
+
return;
|
|
147
|
+
if (panelRef.current?.contains(target))
|
|
148
|
+
return;
|
|
149
|
+
setOpen(false);
|
|
47
150
|
};
|
|
48
151
|
const onKey = (e) => {
|
|
49
|
-
if (e.key
|
|
50
|
-
|
|
152
|
+
if (e.key !== "Escape")
|
|
153
|
+
return;
|
|
154
|
+
setOpen(false);
|
|
155
|
+
// Escape has to return the caret focus, or the user is left tabbing from
|
|
156
|
+
// wherever inside a menu that no longer exists.
|
|
157
|
+
wrapRef.current?.querySelector("button")?.focus();
|
|
51
158
|
};
|
|
52
159
|
document.addEventListener("mousedown", onDown);
|
|
53
160
|
document.addEventListener("keydown", onKey);
|
|
@@ -64,7 +171,7 @@ function DefaultNavItem({ item, isActive, }) {
|
|
|
64
171
|
* one gesture is defined once. See `useHoldToOpen` for why the "did we hold"
|
|
65
172
|
* flag has to be a ref.
|
|
66
173
|
*/
|
|
67
|
-
const holdHandlers = useHoldToOpen(
|
|
174
|
+
const holdHandlers = useHoldToOpen(openNow, hasChildren);
|
|
68
175
|
const trigger = (_jsxs(Link, { href: item.href, "aria-current": isActive ? "page" : undefined, className: `flex items-center gap-[var(--appkit-space-1-5)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] rounded-lg font-medium transition-colors duration-150 whitespace-nowrap ${activeClasses}${hasChildren ? " select-none touch-manipulation" : ""}`, ...holdHandlers, children: [item.icon, _jsx(Span, { children: item.label })] }));
|
|
69
176
|
/*
|
|
70
177
|
* The explicit narrow, not just `hasChildren`. A boolean derived from
|
|
@@ -76,29 +183,29 @@ function DefaultNavItem({ item, isActive, }) {
|
|
|
76
183
|
if (!hasChildren || children.length === 0)
|
|
77
184
|
return trigger;
|
|
78
185
|
/*
|
|
79
|
-
* 🛑
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* Two things this buys. The group stays usable without JS and without a
|
|
83
|
-
* pointer — `focus-within` opens it on Tab, so a keyboard user reaches the
|
|
84
|
-
* children. And the parent is a real link whose children are also reachable
|
|
85
|
-
* from the page it leads to, which is the progressive-enhancement shape
|
|
86
|
-
* rather than a menu that traps people.
|
|
87
|
-
*
|
|
88
|
-
* 🛑 The CARET is now a separate button, and `aria-expanded` is real rather
|
|
89
|
-
* than faked. It used to live INSIDE the parent `<Link>`, so clicking the one
|
|
186
|
+
* 🛑 The CARET is a separate button, and `aria-expanded` is real rather than
|
|
187
|
+
* faked. It used to live INSIDE the parent `<Link>`, so clicking the one
|
|
90
188
|
* control that looks like a disclosure navigated away instead of disclosing —
|
|
91
|
-
* the caret was announcing an affordance the markup did not have.
|
|
92
|
-
* button that owns `aria-expanded`/`aria-controls`, because it is the only
|
|
93
|
-
* part that tracks state; the hover and focus paths still open the panel
|
|
94
|
-
* without touching it, which is why the attribute can be false while the
|
|
95
|
-
* panel is visible. That asymmetry is honest: the button reports what the
|
|
96
|
-
* BUTTON did, and a hover is not a disclosure a screen reader user performed.
|
|
189
|
+
* the caret was announcing an affordance the markup did not have.
|
|
97
190
|
*
|
|
98
|
-
* The
|
|
99
|
-
*
|
|
191
|
+
* The trigger stays a real link, so a tap still follows it and a crawler
|
|
192
|
+
* still gets a destination; the menu is the affordance on top, never a
|
|
193
|
+
* replacement for it.
|
|
100
194
|
*/
|
|
101
|
-
|
|
195
|
+
const panel = (_jsx(Div, { ref: panelRef, id: panelId,
|
|
196
|
+
// Positioning only — the exception the inline-style rule names, since
|
|
197
|
+
// these are runtime measurements with no representable class.
|
|
198
|
+
style: {
|
|
199
|
+
position: "fixed",
|
|
200
|
+
top: coords?.top ?? 0,
|
|
201
|
+
left: coords?.left ?? 0,
|
|
202
|
+
zIndex: "var(--appkit-z-dropdown)",
|
|
203
|
+
// Rendered-but-hidden for exactly one frame: the panel has to be in the
|
|
204
|
+
// DOM for `measure` to read its real width, and `visibility` keeps it
|
|
205
|
+
// measurable without painting it at the placeholder position.
|
|
206
|
+
visibility: coords ? "visible" : "hidden",
|
|
207
|
+
}, onPointerEnter: cancelClose, onPointerLeave: scheduleClose, onFocusCapture: cancelClose, onBlurCapture: scheduleClose, children: _jsx(Div, { surface: "elevated", border: "default", rounded: "lg", shadow: "lg", padding: "xs", children: _jsx(Ul, { className: "min-w-[15rem] list-none", children: children.map((child) => (_jsx(Li, { children: _jsxs(Link, { href: child.href, onClick: () => setOpen(false), className: "flex items-start gap-[var(--appkit-space-2)] rounded-md px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text)] hover:bg-surface-hover transition-colors duration-150", children: [child.icon && (_jsx(Span, { className: "flex-shrink-0 mt-[var(--appkit-space-0-5)]", "aria-hidden": "true", children: child.icon })), _jsxs(Span, { className: "block", children: [_jsx(Span, { weight: "medium", className: "block", children: child.label }), child.description && (_jsx(Span, { size: "xs", color: "muted", className: "block mt-[var(--appkit-space-0-5)] whitespace-normal", children: child.description }))] })] }) }, child.href))) }) }) }));
|
|
208
|
+
return (_jsxs(Div, { ref: wrapRef, className: "relative", onPointerEnter: openNow, onPointerLeave: scheduleClose, onFocusCapture: openNow, onBlurCapture: scheduleClose, children: [_jsxs(Row, { align: "center", gap: "none", children: [trigger, _jsx(IconButton, { type: "button", size: "sm", variant: "ghost", rounded: "md", "aria-expanded": open, "aria-haspopup": "true", "aria-controls": panelId, "aria-label": `${item.label} menu`, onClick: () => (open ? setOpen(false) : openNow()), icon: _jsx(ChevronDown, { className: `w-3.5 h-3.5 shrink-0 opacity-70 transition-transform duration-150 ${open ? "rotate-180" : ""}`, "aria-hidden": "true" }) })] }), open && mounted ? createPortal(panel, document.body) : null] }));
|
|
102
209
|
}
|
|
103
210
|
/**
|
|
104
211
|
* NavbarLayout — generic horizontal navigation bar shell.
|
|
@@ -26,6 +26,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
|
|
|
26
26
|
import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
|
|
27
27
|
import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
|
|
28
28
|
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
29
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
29
30
|
function toDescriptionHtml(raw) {
|
|
30
31
|
if (!raw)
|
|
31
32
|
return "";
|
|
@@ -79,5 +80,5 @@ export async function LiveItemDetailPageView({ slug, initialProduct, renderActio
|
|
|
79
80
|
price: price ?? undefined,
|
|
80
81
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
81
82
|
storeName: storeName ?? undefined,
|
|
82
|
-
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => _jsx(Div, { id: "live-item-buy-bar", children: renderActions?.(product) }), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "live", anchorId: "live-item-buy-bar", price: price, currency: currency })] })] }));
|
|
83
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => _jsx(Div, { id: "live-item-buy-bar", children: renderActions?.(product) }), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "live", anchorId: "live-item-buy-bar", price: price, currency: currency })] })] }));
|
|
83
84
|
}
|
|
@@ -8,6 +8,6 @@ const sizeClasses = {
|
|
|
8
8
|
xl: "w-20 h-20",
|
|
9
9
|
};
|
|
10
10
|
export function MediaAvatar({ src, alt, size = "md", className = "", }) {
|
|
11
|
-
return (_jsx(Div, { className: `relative ${sizeClasses[size]} overflow-hidden flex-shrink-0 ${className}`, rounded: "full", children: _jsx(MediaImage, { src: src, alt: alt, size: "avatar" }) }));
|
|
11
|
+
return (_jsx(Div, { className: `relative ${sizeClasses[size]} overflow-hidden flex-shrink-0 ${className}`, rounded: "full", children: _jsx(MediaImage, { src: src, alt: alt, size: "avatar", objectFit: "cover" }) }));
|
|
12
12
|
}
|
|
13
13
|
export default MediaAvatar;
|
|
@@ -24,7 +24,24 @@ export interface MediaImageProps {
|
|
|
24
24
|
* one slide the visitor can actually see.
|
|
25
25
|
*/
|
|
26
26
|
fetchPriority?: "high" | "low" | "auto";
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* CSS object-fit applied to the underlying img element. Defaults to
|
|
29
|
+
* `'contain'`.
|
|
30
|
+
*
|
|
31
|
+
* 🛑 THE DEFAULT CHANGED on 2026-10-20, from `'cover'`. `cover` fills the
|
|
32
|
+
* box by cropping whatever does not fit, which is right for a photograph
|
|
33
|
+
* that continues past its frame and wrong for this catalogue: most of its
|
|
34
|
+
* imagery is a product shot centred on white, where the crop takes the
|
|
35
|
+
* product and leaves the background. `contain` fits the whole subject
|
|
36
|
+
* inside the shape and letterboxes the remainder against the tile's own
|
|
37
|
+
* surface colour.
|
|
38
|
+
*
|
|
39
|
+
* Pass `'cover'` explicitly where a FILLED box is the component's contract
|
|
40
|
+
* rather than a preference — a full-bleed backdrop with text over it, or a
|
|
41
|
+
* circular avatar. Three call sites do: `MediaAvatar`, and the slide and
|
|
42
|
+
* card backgrounds in `HeroCarousel`. A letterboxed backdrop there would put
|
|
43
|
+
* the dim overlay and the headline on bare surface.
|
|
44
|
+
*/
|
|
28
45
|
objectFit?: "cover" | "contain";
|
|
29
46
|
/**
|
|
30
47
|
* Emoji or text to show when `src` is undefined.
|
|
@@ -67,7 +67,7 @@ function resolveSrcSet(input) {
|
|
|
67
67
|
.filter(Boolean)
|
|
68
68
|
.join(", ");
|
|
69
69
|
}
|
|
70
|
-
export function MediaImage({ src, alt, size = "card", priority = false, loading, fetchPriority, objectFit = "
|
|
70
|
+
export function MediaImage({ src, alt, size = "card", priority = false, loading, fetchPriority, objectFit = "contain", fallback, className, rounded, shadow, style, sources, onError, }) {
|
|
71
71
|
const [hasError, setHasError] = useState(false);
|
|
72
72
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
73
73
|
const [retryCount, setRetryCount] = useState(0);
|
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
{
|
|
2
|
-
"mediaEditor": {
|
|
3
|
-
"cropTitle": "Crop Image",
|
|
4
|
-
"cropInstruction": "Drag to reposition. Use the zoom slider or +/- keys to zoom.",
|
|
5
|
-
"cropPreviewAlt": "Preview",
|
|
6
|
-
"cropZoom": "Zoom",
|
|
7
|
-
"cropZoomIn": "Zoom in",
|
|
8
|
-
"cropZoomOut": "Zoom out",
|
|
9
|
-
"cropRotateLeft": "Rotate left 90°",
|
|
10
|
-
"cropRotateRight": "Rotate right 90°",
|
|
11
|
-
"cropFlipH": "Flip horizontally",
|
|
12
|
-
"cropFlipV": "Flip vertically",
|
|
13
|
-
"cropWarningTitle": "Low zoom",
|
|
14
|
-
"cropWarningMessage": "Zoom in for better quality.",
|
|
15
|
-
"cropPosition": "Position",
|
|
16
|
-
"cropReset": "Reset",
|
|
17
|
-
"cropSave": "Save Crop",
|
|
18
|
-
"cropCancel": "Cancel",
|
|
19
|
-
"trimTitle": "Trim Video",
|
|
20
|
-
"trimInstruction": "Set the start and end times, then click Trim.",
|
|
21
|
-
"trimStart": "Start",
|
|
22
|
-
"trimEnd": "End",
|
|
23
|
-
"trimDuration": "Duration: {duration}s",
|
|
24
|
-
"trimQuality": "Quality",
|
|
25
|
-
"trimQualityLow": "Low",
|
|
26
|
-
"trimQualityMedium": "Medium",
|
|
27
|
-
"trimQualityHigh": "High",
|
|
28
|
-
"trimSave": "Trim Video",
|
|
29
|
-
"trimSkip": "Skip",
|
|
30
|
-
"trimming": "Trimming…",
|
|
31
|
-
"thumbnailTitle": "Select Thumbnail",
|
|
32
|
-
"thumbnailInstruction": "Seek to the frame you want to use as the thumbnail.",
|
|
33
|
-
"thumbnailCapture": "Use This Frame",
|
|
34
|
-
"thumbnailSkip": "Skip",
|
|
35
|
-
"thumbnailCapturing": "Capturing…",
|
|
36
|
-
"thumbnailUploading": "Uploading…",
|
|
37
|
-
"cropSuccess": "Image cropped",
|
|
38
|
-
"trimSuccess": "Video trimmed",
|
|
39
|
-
"thumbnailSuccess": "Thumbnail saved",
|
|
40
|
-
"cropError": "Failed to crop image",
|
|
41
|
-
"trimError": "Failed to trim video",
|
|
42
|
-
"thumbnailError": "Failed to capture thumbnail",
|
|
43
|
-
"editImage": "Edit / Crop",
|
|
44
|
-
"editVideo": "Trim / Thumbnail"
|
|
45
|
-
},
|
|
46
|
-
"camera": {
|
|
47
|
-
"takePhoto": "Take Photo",
|
|
48
|
-
"startRecording": "Start Recording",
|
|
49
|
-
"stopRecording": "Stop Recording",
|
|
50
|
-
"flipCamera": "Flip Camera",
|
|
51
|
-
"switchToCamera": "Use Camera",
|
|
52
|
-
"switchToUpload": "Upload File",
|
|
53
|
-
"cameraError": "Camera unavailable",
|
|
54
|
-
"permissionDenied": "Camera permission denied. Please allow access in your browser settings.",
|
|
55
|
-
"notSupported": "Camera is not supported on this device",
|
|
56
|
-
"starting": "Starting camera..."
|
|
57
|
-
},
|
|
58
|
-
"upload": {
|
|
59
|
-
"change": "Change",
|
|
60
|
-
"remove": "Remove",
|
|
61
|
-
"clickToUpload": "Click to upload",
|
|
62
|
-
"uploading": "Uploading…",
|
|
63
|
-
"uploadingProgress": "Uploading {progress}%…",
|
|
64
|
-
"replaceFile": "Replace file",
|
|
65
|
-
"chooseFile": "Choose file to upload"
|
|
66
|
-
}
|
|
67
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"mediaEditor": {
|
|
3
|
+
"cropTitle": "Crop Image",
|
|
4
|
+
"cropInstruction": "Drag to reposition. Use the zoom slider or +/- keys to zoom.",
|
|
5
|
+
"cropPreviewAlt": "Preview",
|
|
6
|
+
"cropZoom": "Zoom",
|
|
7
|
+
"cropZoomIn": "Zoom in",
|
|
8
|
+
"cropZoomOut": "Zoom out",
|
|
9
|
+
"cropRotateLeft": "Rotate left 90°",
|
|
10
|
+
"cropRotateRight": "Rotate right 90°",
|
|
11
|
+
"cropFlipH": "Flip horizontally",
|
|
12
|
+
"cropFlipV": "Flip vertically",
|
|
13
|
+
"cropWarningTitle": "Low zoom",
|
|
14
|
+
"cropWarningMessage": "Zoom in for better quality.",
|
|
15
|
+
"cropPosition": "Position",
|
|
16
|
+
"cropReset": "Reset",
|
|
17
|
+
"cropSave": "Save Crop",
|
|
18
|
+
"cropCancel": "Cancel",
|
|
19
|
+
"trimTitle": "Trim Video",
|
|
20
|
+
"trimInstruction": "Set the start and end times, then click Trim.",
|
|
21
|
+
"trimStart": "Start",
|
|
22
|
+
"trimEnd": "End",
|
|
23
|
+
"trimDuration": "Duration: {duration}s",
|
|
24
|
+
"trimQuality": "Quality",
|
|
25
|
+
"trimQualityLow": "Low",
|
|
26
|
+
"trimQualityMedium": "Medium",
|
|
27
|
+
"trimQualityHigh": "High",
|
|
28
|
+
"trimSave": "Trim Video",
|
|
29
|
+
"trimSkip": "Skip",
|
|
30
|
+
"trimming": "Trimming…",
|
|
31
|
+
"thumbnailTitle": "Select Thumbnail",
|
|
32
|
+
"thumbnailInstruction": "Seek to the frame you want to use as the thumbnail.",
|
|
33
|
+
"thumbnailCapture": "Use This Frame",
|
|
34
|
+
"thumbnailSkip": "Skip",
|
|
35
|
+
"thumbnailCapturing": "Capturing…",
|
|
36
|
+
"thumbnailUploading": "Uploading…",
|
|
37
|
+
"cropSuccess": "Image cropped",
|
|
38
|
+
"trimSuccess": "Video trimmed",
|
|
39
|
+
"thumbnailSuccess": "Thumbnail saved",
|
|
40
|
+
"cropError": "Failed to crop image",
|
|
41
|
+
"trimError": "Failed to trim video",
|
|
42
|
+
"thumbnailError": "Failed to capture thumbnail",
|
|
43
|
+
"editImage": "Edit / Crop",
|
|
44
|
+
"editVideo": "Trim / Thumbnail"
|
|
45
|
+
},
|
|
46
|
+
"camera": {
|
|
47
|
+
"takePhoto": "Take Photo",
|
|
48
|
+
"startRecording": "Start Recording",
|
|
49
|
+
"stopRecording": "Stop Recording",
|
|
50
|
+
"flipCamera": "Flip Camera",
|
|
51
|
+
"switchToCamera": "Use Camera",
|
|
52
|
+
"switchToUpload": "Upload File",
|
|
53
|
+
"cameraError": "Camera unavailable",
|
|
54
|
+
"permissionDenied": "Camera permission denied. Please allow access in your browser settings.",
|
|
55
|
+
"notSupported": "Camera is not supported on this device",
|
|
56
|
+
"starting": "Starting camera..."
|
|
57
|
+
},
|
|
58
|
+
"upload": {
|
|
59
|
+
"change": "Change",
|
|
60
|
+
"remove": "Remove",
|
|
61
|
+
"clickToUpload": "Click to upload",
|
|
62
|
+
"uploading": "Uploading…",
|
|
63
|
+
"uploadingProgress": "Uploading {progress}%…",
|
|
64
|
+
"replaceFile": "Replace file",
|
|
65
|
+
"chooseFile": "Choose file to upload"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { ListingType } from "../../products/types";
|
|
|
8
8
|
import type { StatusChangeEntry } from "../../../_internal/shared/history/index";
|
|
9
9
|
import type { ReturnReason } from "../../../_internal/shared/features/orders/return-reasons";
|
|
10
10
|
import type { ReturnCustodyState } from "../../../_internal/shared/features/returns/custody";
|
|
11
|
+
import type { ActualShipmentCost, SelectedShippingQuote, ShippingReconciliation } from "../../../_internal/shared/shipping/quote";
|
|
11
12
|
import type { Encrypted, Plaintext } from "../../../security/encrypted";
|
|
12
13
|
export interface OrderSourceStandard {
|
|
13
14
|
path: "standard";
|
|
@@ -449,7 +450,7 @@ export interface OrderDocument extends BaseDocument {
|
|
|
449
450
|
platformFee?: number;
|
|
450
451
|
depositAmount?: number;
|
|
451
452
|
codRemainingAmount?: number;
|
|
452
|
-
/** COD handling fee charged to the buyer:
|
|
453
|
+
/** COD handling fee charged to the buyer: min(subtotal × codHandlingFeePercent / 100, codHandlingFeeCap). Only set when paymentMethod === "cod". */
|
|
453
454
|
codHandlingFee?: number;
|
|
454
455
|
/** Buyer opted into gift wrapping at checkout (unchecked by default). */
|
|
455
456
|
giftWrapAddon?: boolean;
|
|
@@ -550,6 +551,43 @@ export interface OrderDocument extends BaseDocument {
|
|
|
550
551
|
shippingMethod?: ShippingMethod;
|
|
551
552
|
shippingCarrier?: string;
|
|
552
553
|
trackingUrl?: string;
|
|
554
|
+
/**
|
|
555
|
+
* The courier the BUYER picked at checkout, and what they were charged for
|
|
556
|
+
* it, frozen at order creation.
|
|
557
|
+
*
|
|
558
|
+
* 🛑 Stored, never recomputed. Carrier rates move hourly, so re-quoting at
|
|
559
|
+
* reconciliation time would compare the seller's real spend against a price
|
|
560
|
+
* nobody was ever charged, and the buyer's refund would depend on when a job
|
|
561
|
+
* happened to run. Same reasoning as `sourceContext`.
|
|
562
|
+
*
|
|
563
|
+
* `chargedRate` is the ₹50-rounded figure and is the ONLY one that is
|
|
564
|
+
* billed; `rawRate` is kept for the audit trail and is never rendered.
|
|
565
|
+
* Absent on orders placed before the picker shipped, and on any order whose
|
|
566
|
+
* seller ships manually — so every reader must treat absence as "no quote
|
|
567
|
+
* was taken", never as zero.
|
|
568
|
+
*/
|
|
569
|
+
selectedShippingQuote?: SelectedShippingQuote;
|
|
570
|
+
/**
|
|
571
|
+
* What the seller actually spent on postage, recorded at dispatch.
|
|
572
|
+
*
|
|
573
|
+
* Written by the seller's ship action. Its presence is what makes an order
|
|
574
|
+
* eligible for reconciliation; its absence means the parcel has not been
|
|
575
|
+
* dispatched or the seller has not told us what it cost.
|
|
576
|
+
*/
|
|
577
|
+
actualShipmentCost?: ActualShipmentCost;
|
|
578
|
+
/**
|
|
579
|
+
* The outcome of comparing the two above — computed ONCE, when the seller
|
|
580
|
+
* dispatches, and then left alone.
|
|
581
|
+
*
|
|
582
|
+
* 🛑 Its presence is the idempotency key for the points credit. Crediting is
|
|
583
|
+
* an append to a ledger, so a job that ran twice would pay the buyer twice
|
|
584
|
+
* and nothing downstream could tell the difference between that and two
|
|
585
|
+
* genuine savings. Every writer must check this field is unset before
|
|
586
|
+
* writing a `shipping_saving` entry.
|
|
587
|
+
*/
|
|
588
|
+
shippingReconciliation?: ShippingReconciliation & {
|
|
589
|
+
reconciledAt: Date;
|
|
590
|
+
};
|
|
553
591
|
/**
|
|
554
592
|
* The carrier's label PDF, once a shipment has been booked.
|
|
555
593
|
*
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type PointsEntryKind, type PointsLedgerEntry } from "../schemas/firestore";
|
|
2
|
+
/**
|
|
3
|
+
* The points ledger: append-only entries, and a balance that is their sum.
|
|
4
|
+
*
|
|
5
|
+
* ## 🛑 Every mutation is a TRANSACTION over the ledger and the mirror
|
|
6
|
+
*
|
|
7
|
+
* `users/{uid}.pointsBalance` is a denormalised mirror of the ledger, kept so
|
|
8
|
+
* a page can show a balance in one read instead of scanning an unbounded
|
|
9
|
+
* collection. Root Cause #42 is explicit about what happens to a mirror the
|
|
10
|
+
* moment one write path forgets it, and this mirror guards MONEY-SHAPED value:
|
|
11
|
+
* drift here either hands a buyer points nobody credited, or swallows points
|
|
12
|
+
* they earned.
|
|
13
|
+
*
|
|
14
|
+
* So the entry and the mirror move together or not at all, and the mirror is
|
|
15
|
+
* incremented from the value read INSIDE the transaction rather than from a
|
|
16
|
+
* figure the caller passed in — two concurrent credits that each read "53" and
|
|
17
|
+
* each wrote "106" would lose one of them.
|
|
18
|
+
*
|
|
19
|
+
* `redeem()` additionally re-checks the balance inside the transaction. A
|
|
20
|
+
* caller that validated the balance a moment earlier is working from a read
|
|
21
|
+
* that may already be stale, and the failure mode is a negative balance — the
|
|
22
|
+
* one state this ledger must never reach.
|
|
23
|
+
*/
|
|
24
|
+
export declare class PointsRepository {
|
|
25
|
+
/**
|
|
26
|
+
* Current balance, from the mirror.
|
|
27
|
+
*
|
|
28
|
+
* 🛑 Falls back to summing the ledger when the mirror is absent, rather than
|
|
29
|
+
* returning 0. An unseeded or pre-feature user document has no
|
|
30
|
+
* `pointsBalance`, and answering "0" for someone with real credits would
|
|
31
|
+
* silently deny them money they are owed. Absence means "not computed yet",
|
|
32
|
+
* never "nothing".
|
|
33
|
+
*/
|
|
34
|
+
getBalance(userId: string): Promise<number>;
|
|
35
|
+
/**
|
|
36
|
+
* The authoritative balance, computed from the entries themselves.
|
|
37
|
+
*
|
|
38
|
+
* Unbounded by nature, so it is NOT the read path — it exists to repair the
|
|
39
|
+
* mirror and to let a reconciliation job prove the two agree.
|
|
40
|
+
*/
|
|
41
|
+
sumLedger(userId: string): Promise<number>;
|
|
42
|
+
/**
|
|
43
|
+
* Append an entry and move the mirror, atomically.
|
|
44
|
+
*
|
|
45
|
+
* Returns the new balance so a caller can report it without a second read.
|
|
46
|
+
*/
|
|
47
|
+
append(input: {
|
|
48
|
+
userId: string;
|
|
49
|
+
kind: PointsEntryKind;
|
|
50
|
+
amount: number;
|
|
51
|
+
description: string;
|
|
52
|
+
orderId?: string;
|
|
53
|
+
adminReason?: string;
|
|
54
|
+
actorUid?: string;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
entryId: string;
|
|
57
|
+
balance: number;
|
|
58
|
+
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Spend points against an order.
|
|
61
|
+
*
|
|
62
|
+
* 🛑 `points` is given POSITIVE and stored NEGATIVE. The caller is saying
|
|
63
|
+
* "spend 53", and making them pass `-53` is an invitation to pass `53` by
|
|
64
|
+
* mistake and credit somebody instead of charging them.
|
|
65
|
+
*
|
|
66
|
+
* Re-reads the balance inside the transaction and refuses rather than going
|
|
67
|
+
* negative — see the class header.
|
|
68
|
+
*/
|
|
69
|
+
redeem(input: {
|
|
70
|
+
userId: string;
|
|
71
|
+
points: number;
|
|
72
|
+
orderId: string;
|
|
73
|
+
description: string;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
entryId: string;
|
|
76
|
+
balance: number;
|
|
77
|
+
}>;
|
|
78
|
+
/** Most recent entries first, for the buyer's own points page. */
|
|
79
|
+
listForUser(userId: string, limit?: number): Promise<PointsLedgerEntry[]>;
|
|
80
|
+
}
|
|
81
|
+
export declare const pointsRepository: PointsRepository;
|