@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.
Files changed (86) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
  3. package/dist/_internal/server/features/checkout/actions.js +100 -5
  4. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  5. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  6. package/dist/_internal/shared/config/schema.d.ts +6 -0
  7. package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
  8. package/dist/_internal/shared/features/checkout/config.js +1 -1
  9. package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
  10. package/dist/_internal/shared/features/site-settings/fees.js +1 -1
  11. package/dist/_internal/shared/fees/calculator.d.ts +35 -3
  12. package/dist/_internal/shared/fees/calculator.js +34 -4
  13. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  14. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  15. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  16. package/dist/_internal/shared/shipping/quote.js +124 -0
  17. package/dist/client.d.ts +1 -0
  18. package/dist/client.js +8 -0
  19. package/dist/errors/error-codes.d.ts +15 -0
  20. package/dist/errors/error-codes.js +15 -0
  21. package/dist/errors/index.d.ts +1 -1
  22. package/dist/errors/index.js +1 -1
  23. package/dist/features/account/components/UserBidsView.js +4 -2
  24. package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
  25. package/dist/features/admin/schemas/firestore.d.ts +37 -2
  26. package/dist/features/admin/schemas/firestore.js +12 -1
  27. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  28. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  29. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  30. package/dist/features/checkout/schemas/firestore.d.ts +12 -1
  31. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  32. package/dist/features/homepage/components/HeroCarousel.js +4 -2
  33. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  34. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  35. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  36. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  37. package/dist/features/homepage/lib/section-renderer.js +22 -5
  38. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  39. package/dist/features/homepage/lib/slide-gating.js +98 -0
  40. package/dist/features/layout/NavbarLayout.js +139 -32
  41. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  42. package/dist/features/media/MediaAvatar.js +1 -1
  43. package/dist/features/media/MediaImage.d.ts +18 -1
  44. package/dist/features/media/MediaImage.js +1 -1
  45. package/dist/features/media/messages/en.json +67 -67
  46. package/dist/features/orders/schemas/firestore.d.ts +39 -1
  47. package/dist/features/points/repository/points.repository.d.ts +81 -0
  48. package/dist/features/points/repository/points.repository.js +155 -0
  49. package/dist/features/points/schemas/firestore.d.ts +102 -0
  50. package/dist/features/points/schemas/firestore.js +99 -0
  51. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  52. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  53. package/dist/features/products/components/ProductDetailPageView.js +5 -26
  54. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
  55. package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
  56. package/dist/features/products/components/ProductsIndexListing.js +35 -5
  57. package/dist/features/products/components/ProductsIndexPageView.js +40 -7
  58. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  59. package/dist/features/seller/actions/seller-actions.js +33 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +8 -0
  62. package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
  63. package/dist/providers/shipping-shiprocket/index.js +34 -10
  64. package/dist/security/csp.d.ts +1 -16
  65. package/dist/security/csp.js +3 -5
  66. package/dist/seed/carousel-slides-seed-data.js +48 -7
  67. package/dist/seed/faq-seed-data.js +1 -1
  68. package/dist/seed/site-settings-seed-data.js +1 -1
  69. package/dist/seo/json-ld.d.ts +8 -1
  70. package/dist/seo/json-ld.js +39 -2
  71. package/dist/styles.css +419 -542
  72. package/dist/tailwind-utilities.css +1 -1
  73. package/dist/tokens/themes/index.d.ts +1 -3
  74. package/dist/tokens/themes/index.js +1 -11
  75. package/dist/tokens/tokens.css +0 -155
  76. package/dist/ui/components/GatedPrice.style.css +43 -43
  77. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  78. package/dist/ui/components/ImageMosaic.js +28 -5
  79. package/dist/ui/components/ImageMosaic.style.css +37 -5
  80. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  81. package/dist/ui/forms/FormShell.style.css +260 -260
  82. package/package.json +1 -1
  83. package/dist/tokens/themes/technical-dark.d.ts +0 -30
  84. package/dist/tokens/themes/technical-dark.js +0 -103
  85. package/dist/tokens/themes/technical-light.d.ts +0 -22
  86. 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
- * Click-to-open state, which exists ONLY for the caret.
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
- * 🛑 Hover and focus stay pure CSS below, and this does not replace them. The
24
- * panel opens on `group-hover` / `group-focus-within` with no JS at all, which
25
- * is what keeps it usable for a keyboard user on Tab and before hydration.
26
- * This adds the third affordance that was genuinely missing: a pointer user
27
- * who CLICKS the caret expecting a menu got a navigation instead, because the
28
- * caret was inside the parent `<Link>`.
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
- if (!wrapRef.current?.contains(e.target))
46
- setOpen(false);
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 === "Escape")
50
- setOpen(false);
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(() => setOpen(true), hasChildren);
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
- * 🛑 Hover/focus-within CSS, not React state, and the trigger is still a real
80
- * link.
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. It is the
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 `pt` on the panel is a hover bridge: without it the gap between the
99
- * trigger and the panel closes the menu as the pointer crosses it.
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
- return (_jsxs(Div, { ref: wrapRef, className: "relative group", 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: () => setOpen((v) => !v), icon: _jsx(ChevronDown, { className: `w-3.5 h-3.5 shrink-0 opacity-70 transition-transform duration-150 ${open ? "rotate-180" : ""}`, "aria-hidden": "true" }) })] }), _jsx(Div, { id: panelId, className: `absolute left-0 top-full z-20 pt-[var(--appkit-space-1)] group-hover:block group-focus-within:block ${open ? "block" : "hidden"}`, 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, 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))) }) }) })] }));
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
- /** CSS object-fit applied to the underlying img element. Defaults to `'cover'`. */
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 = "cover", fallback, className, rounded, shadow, style, sources, onError, }) {
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: max(codHandlingFeeMin, subtotal × codHandlingFeePercent / 100). Only set when paymentMethod === "cod". */
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;