@mohasinac/appkit 4.54.1 → 4.55.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 +3 -3
- package/dist/_internal/server/features/checkout/actions.js +61 -2
- 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/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/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/checkout/schemas/firestore.d.ts +12 -1
- package/dist/features/layout/NavbarLayout.js +139 -32
- package/dist/features/orders/schemas/firestore.d.ts +1 -1
- package/dist/features/products/components/ProductDetailPageView.js +2 -25
- 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/providers/shipping-shiprocket/index.d.ts +13 -4
- package/dist/providers/shipping-shiprocket/index.js +34 -10
- 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 +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -449,7 +449,7 @@ export interface OrderDocument extends BaseDocument {
|
|
|
449
449
|
platformFee?: number;
|
|
450
450
|
depositAmount?: number;
|
|
451
451
|
codRemainingAmount?: number;
|
|
452
|
-
/** COD handling fee charged to the buyer:
|
|
452
|
+
/** COD handling fee charged to the buyer: min(subtotal × codHandlingFeePercent / 100, codHandlingFeeCap). Only set when paymentMethod === "cod". */
|
|
453
453
|
codHandlingFee?: number;
|
|
454
454
|
/** Buyer opted into gift wrapping at checkout (unchecked by default). */
|
|
455
455
|
giftWrapAddon?: boolean;
|
|
@@ -31,8 +31,7 @@ import { ctaAnchorProps } from "../../layout/cta-anchor.js";
|
|
|
31
31
|
import { ContextualTrail } from "../../categories/components/ContextualTrail.js";
|
|
32
32
|
import { productCrumbs, withTrailHead } from "../../categories/utils/breadcrumb-chain.js";
|
|
33
33
|
import { getDefaultCurrency } from "../../../core/baseline-resolver.js";
|
|
34
|
-
import {
|
|
35
|
-
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
34
|
+
import { ProductOwnerQuickActions } from "./ProductOwnerQuickActions.js";
|
|
36
35
|
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
37
36
|
import { Button, Container, Div, Heading, Main, PriceDisplay, RichText, Row, Section, Span, Stack, Text, Ul, Ol, Li, SpecTable, Icon, } from "../../../ui/index.js";
|
|
38
37
|
import { normalizeRichTextHtml } from "../../../utils/string.formatter.js";
|
|
@@ -353,29 +352,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
353
352
|
price: product.price,
|
|
354
353
|
storeId: product.storeId,
|
|
355
354
|
storeName: product.storeName,
|
|
356
|
-
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(ContextualTrail, { trails: trails, alternates: { ids: categoryIds, names: categoryIdNames } }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(
|
|
357
|
-
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_DUPLICATE(String(product.id)), { method: "POST" });
|
|
358
|
-
if (!res.ok)
|
|
359
|
-
return;
|
|
360
|
-
const json = (await res.json());
|
|
361
|
-
const newId = json?.data?.id;
|
|
362
|
-
/*
|
|
363
|
-
* Land on the COPY's editor. The endpoint returns the new
|
|
364
|
-
* draft's id precisely so a one-click clone ends somewhere
|
|
365
|
-
* useful rather than back on the public page, where the
|
|
366
|
-
* draft is invisible.
|
|
367
|
-
*/
|
|
368
|
-
if (newId && typeof window !== "undefined") {
|
|
369
|
-
window.location.assign(String(ROUTES.STORE.PRODUCTS_EDIT(newId)));
|
|
370
|
-
}
|
|
371
|
-
}, onDelete: async () => {
|
|
372
|
-
await fetch(SELLER_ENDPOINTS.PRODUCT_BY_ID(String(product.id)), {
|
|
373
|
-
method: "DELETE",
|
|
374
|
-
});
|
|
375
|
-
if (typeof window !== "undefined") {
|
|
376
|
-
window.location.assign(String(ROUTES.PUBLIC.PRODUCTS));
|
|
377
|
-
}
|
|
378
|
-
} }), _jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
|
|
355
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsx(ProductDetailView, { renderBreadcrumb: () => (_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsx(ContextualTrail, { trails: trails, alternates: { ids: categoryIds, names: categoryIdNames } }), _jsx(ShareButton, { title: title || undefined })] })), renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title || undefined })), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsx(ProductOwnerQuickActions, { productId: String(product.id), recordLabel: title || String(product.slug ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsxs(Div, { children: [condition && (_jsx(Span, { size: "xs", weight: "medium", className: "mb-2 inline-block", padding: "pill-sm", rounded: "full", surface: "subtle", color: "muted", transform: "capitalize", children: condition === "new"
|
|
379
356
|
? "Brand New"
|
|
380
357
|
: condition === "like_new"
|
|
381
358
|
? "Like New"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ProductOwnerQuickActionsProps {
|
|
2
|
+
/** The product's document id — what both endpoints are keyed on. */
|
|
3
|
+
productId: string;
|
|
4
|
+
/** Store SLUG. `SessionUser.storeId` holds the slug, not the Auth UID. */
|
|
5
|
+
storeId?: string;
|
|
6
|
+
/** Names the record in the delete confirmation. */
|
|
7
|
+
recordLabel?: string;
|
|
8
|
+
placement?: "card" | "detail";
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function ProductOwnerQuickActions({ productId, storeId, recordLabel, placement, className, }: ProductOwnerQuickActionsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The product flavour of `OwnerQuickActions` — pencil, admin clone, bin.
|
|
5
|
+
*
|
|
6
|
+
* ## 🛑 Why this file exists at all
|
|
7
|
+
*
|
|
8
|
+
* `OwnerQuickActions` takes `onClone` / `onDelete` as FUNCTIONS, and
|
|
9
|
+
* `ProductDetailPageView` is a Server Component. A function cannot cross that
|
|
10
|
+
* boundary: React throws
|
|
11
|
+
*
|
|
12
|
+
* Event handlers cannot be passed to Client Component props.
|
|
13
|
+
* {resource: "PRODUCT", …, onClone: function onClone, onDelete: …}
|
|
14
|
+
*
|
|
15
|
+
* at render, which took down EVERY `/products/[slug]` page in production with
|
|
16
|
+
* an opaque React error — two digests, one per handler. It is not a schema
|
|
17
|
+
* mismatch and no `<Suspense>` boundary affects it: Suspense governs when a
|
|
18
|
+
* subtree may stream, serialization governs what may be sent, and a closure is
|
|
19
|
+
* never sendable.
|
|
20
|
+
*
|
|
21
|
+
* So the closures have to be authored on the client side of the boundary. This
|
|
22
|
+
* component is that side: the Server Component hands it strings, and it builds
|
|
23
|
+
* the handlers here.
|
|
24
|
+
*
|
|
25
|
+
* ## 🛑 Why not just put `"use client"` on the detail view
|
|
26
|
+
*
|
|
27
|
+
* It renders the whole product page, pulls server-resolved data through a
|
|
28
|
+
* dozen render props, and is the SEO surface for every listing. Marking it a
|
|
29
|
+
* Client Component would drag all of that into the browser bundle to fix two
|
|
30
|
+
* buttons. The boundary belongs at the smallest thing that needs it.
|
|
31
|
+
*
|
|
32
|
+
* ## 🛑 Why ids, not endpoint strings
|
|
33
|
+
*
|
|
34
|
+
* An earlier shape passed `cloneEndpoint` / `deleteEndpoint` as props. That is
|
|
35
|
+
* one more pair of strings a caller can get wrong, and the clone's redirect
|
|
36
|
+
* target depends on the id the endpoint RETURNS, which no caller can know in
|
|
37
|
+
* advance. Taking the product id and deriving all three here keeps the
|
|
38
|
+
* endpoints next to the code that reads their responses.
|
|
39
|
+
*
|
|
40
|
+
* @tag domain:products
|
|
41
|
+
* @tag layer:components
|
|
42
|
+
* @tag access:client
|
|
43
|
+
*/
|
|
44
|
+
import { useCallback } from "react";
|
|
45
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
46
|
+
import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
47
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
48
|
+
import { useToast } from "../../../ui/index.js";
|
|
49
|
+
import { normalizeError } from "../../../errors/normalize.js";
|
|
50
|
+
import { toUserMessage } from "../../../errors/error-display-map.js";
|
|
51
|
+
export function ProductOwnerQuickActions({ productId, storeId, recordLabel, placement = "detail", className, }) {
|
|
52
|
+
const { showToast } = useToast();
|
|
53
|
+
/*
|
|
54
|
+
* Clone reuses POST /api/store/products/[id]/duplicate, which already strips
|
|
55
|
+
* identity, analytics, bid state and group membership and forces
|
|
56
|
+
* status: draft — hence no confirmation on it, and admin-only.
|
|
57
|
+
*/
|
|
58
|
+
const handleClone = useCallback(async () => {
|
|
59
|
+
/*
|
|
60
|
+
* 🛑 Clone reports its own failures. `OwnerQuickActions` catches and toasts
|
|
61
|
+
* for DELETE only — it invokes clone as `void onClone?.()`, so a rejection
|
|
62
|
+
* here would be an unhandled promise and nothing at all would appear on
|
|
63
|
+
* screen. The previous inline version was worse than silent: it did
|
|
64
|
+
* `if (!res.ok) return`, so a refused clone looked exactly like a button
|
|
65
|
+
* that does nothing, and the obvious response is to press it again.
|
|
66
|
+
*/
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_DUPLICATE(productId), {
|
|
69
|
+
method: "POST",
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const body = (await res.json().catch(() => null));
|
|
73
|
+
showToast(toUserMessage(body?.code ?? "UNKNOWN"), "error");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const json = (await res.json());
|
|
77
|
+
const newId = json?.data?.id;
|
|
78
|
+
/*
|
|
79
|
+
* Land on the COPY's editor. The endpoint returns the new draft's id
|
|
80
|
+
* precisely so a one-click clone ends somewhere useful rather than back
|
|
81
|
+
* on the public page, where a draft is invisible.
|
|
82
|
+
*/
|
|
83
|
+
if (newId && typeof window !== "undefined") {
|
|
84
|
+
window.location.assign(String(ROUTES.STORE.PRODUCTS_EDIT(newId)));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
* A 2xx with no id is a successful call that cannot be acted on — the
|
|
89
|
+
* copy may well exist, so saying "failed" would be wrong, and saying
|
|
90
|
+
* nothing leaves the admin believing the click was ignored.
|
|
91
|
+
*/
|
|
92
|
+
showToast("Copy created, but its editor could not be opened.", "info");
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const normalized = normalizeError(err);
|
|
96
|
+
showToast(toUserMessage(normalized.code ?? "UNKNOWN"), "error");
|
|
97
|
+
}
|
|
98
|
+
}, [productId, showToast]);
|
|
99
|
+
/*
|
|
100
|
+
* 🛑 Throws on a failed DELETE rather than returning quietly.
|
|
101
|
+
* `OwnerQuickActions.handleConfirmedDelete` catches and toasts — and because
|
|
102
|
+
* success navigates away, a swallowed failure is indistinguishable from "it
|
|
103
|
+
* worked but the page did not move". The previous inline version ignored
|
|
104
|
+
* `res.ok` entirely and navigated regardless, so a refused delete sent the
|
|
105
|
+
* owner to the product list as though it had succeeded.
|
|
106
|
+
*/
|
|
107
|
+
const handleDelete = useCallback(async () => {
|
|
108
|
+
const res = await fetch(SELLER_ENDPOINTS.PRODUCT_BY_ID(productId), {
|
|
109
|
+
method: "DELETE",
|
|
110
|
+
});
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
const body = (await res.json().catch(() => null));
|
|
113
|
+
throw Object.assign(new Error("Delete failed"), { code: body?.code });
|
|
114
|
+
}
|
|
115
|
+
if (typeof window !== "undefined") {
|
|
116
|
+
window.location.assign(String(ROUTES.PUBLIC.PRODUCTS));
|
|
117
|
+
}
|
|
118
|
+
}, [productId]);
|
|
119
|
+
return (_jsx(OwnerQuickActions, { resource: "PRODUCT", placement: placement, className: className, recordLabel: recordLabel, storeId: storeId, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(productId)), onClone: handleClone, onDelete: handleDelete }));
|
|
120
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useCallback } from "react";
|
|
3
|
+
import { useState, useCallback, useMemo } from "react";
|
|
4
4
|
import { ShoppingCart, Heart, Columns } from "lucide-react";
|
|
5
5
|
import { useRouter } from "next/navigation.js";
|
|
6
6
|
import { useUrlTable } from "../../../react/hooks/useUrlTable.js";
|
|
@@ -31,6 +31,7 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names.js";
|
|
|
31
31
|
import { useBottomActions } from "../../layout/index.js";
|
|
32
32
|
import { GENERIC_PRODUCT_LISTING_TYPES, PRODUCT_TYPE_FILTER_TABS } from "../constants/listing-tabs.js";
|
|
33
33
|
import { parseSelectedListingTypes } from "../utils/listing-type.js";
|
|
34
|
+
import { useListingTypeFlags } from "../../../react/hooks/useListingTypeFlags.js";
|
|
34
35
|
import { commonSortOptionsFor } from "../../../_internal/shared/listing-types/_registry.js";
|
|
35
36
|
import { AVAILABILITY_VALUES } from "../../../constants/field-names.js";
|
|
36
37
|
import { AvailabilityTabs } from "./AvailabilityTabs.js";
|
|
@@ -77,13 +78,42 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
|
|
|
77
78
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
78
79
|
const [compareIds, setCompareIds] = useState([]);
|
|
79
80
|
const [view, setView] = useState(table.get(TABLE_KEYS.VIEW) || VIEW_MODE.GRID);
|
|
81
|
+
/*
|
|
82
|
+
* ── What this page actually spans ────────────────────────────────────────
|
|
83
|
+
*
|
|
84
|
+
* 🛑 A type an admin has switched OFF must not appear as a filter option.
|
|
85
|
+
* `listingTypes` / `typeTabs` describe what the page covers by DESIGN
|
|
86
|
+
* (`GENERIC_PRODUCT_LISTING_TYPES`, or a store tab's own set); the flags
|
|
87
|
+
* describe what this site offers. Only the intersection is real.
|
|
88
|
+
*
|
|
89
|
+
* Ticking a disabled type was not merely untidy — `listPublicProducts`
|
|
90
|
+
* narrows the request to `selected ∩ span` and falls back to the FULL span
|
|
91
|
+
* when that intersection is empty, so a chip for a disabled type silently
|
|
92
|
+
* WIDENED the results instead of narrowing them (the same trap documented
|
|
93
|
+
* for out-of-span types on `/products`).
|
|
94
|
+
*
|
|
95
|
+
* Falling back to the ungated sets when the filter empties them is
|
|
96
|
+
* deliberate: an empty span is not "show nothing", it is a settings document
|
|
97
|
+
* that disabled everything this page covers, and rendering a type-less
|
|
98
|
+
* listing is more useful than rendering an empty one. `useListingTypeFlags`
|
|
99
|
+
* already fails open while settings load, so there is no first-paint flash.
|
|
100
|
+
*/
|
|
101
|
+
const listingTypeFlags = useListingTypeFlags();
|
|
102
|
+
const enabledSpan = useMemo(() => {
|
|
103
|
+
const kept = listingTypes.filter((t) => listingTypeFlags[t]);
|
|
104
|
+
return (kept.length > 0 ? kept : listingTypes);
|
|
105
|
+
}, [listingTypes, listingTypeFlags]);
|
|
106
|
+
const enabledTypeTabs = useMemo(() => {
|
|
107
|
+
const kept = typeTabs.filter((tab) => enabledSpan.includes(tab.id));
|
|
108
|
+
return kept.length > 0 ? kept : typeTabs;
|
|
109
|
+
}, [typeTabs, enabledSpan]);
|
|
80
110
|
// ── Type filter (multi-select) ───────────────────────────────────────────
|
|
81
111
|
// The URL value is a pipe-joined set; empty means "every type this page
|
|
82
112
|
// spans". Unknown tokens are dropped rather than passed through to a
|
|
83
113
|
// Firestore `==` that could never match (Root Cause #33).
|
|
84
114
|
const typeParam = table.get(TABLE_KEYS.LISTING_TYPE);
|
|
85
|
-
const selectedTypes = parseSelectedListingTypes(typeParam).filter((t) =>
|
|
86
|
-
const effectiveTypes = (selectedTypes.length > 0 ? selectedTypes :
|
|
115
|
+
const selectedTypes = parseSelectedListingTypes(typeParam).filter((t) => enabledSpan.includes(t));
|
|
116
|
+
const effectiveTypes = (selectedTypes.length > 0 ? selectedTypes : enabledSpan);
|
|
87
117
|
// Sorts valid across every selected type. Pick Auctions alone and you get
|
|
88
118
|
// "Ending Soon"; pick Auctions + Products and you get only what both
|
|
89
119
|
// support, because a sort on `auctionEndDate` would order the products
|
|
@@ -109,9 +139,9 @@ export function ProductsIndexListing({ initialData, listingTypes = GENERIC_PRODU
|
|
|
109
139
|
// The drawer's own, uncommitted type selection. The "Full <type> filters →"
|
|
110
140
|
// link tracks THIS rather than the applied URL value, so it appears the
|
|
111
141
|
// moment the user ticks a single type instead of only after Apply.
|
|
112
|
-
const pendingTypes = parseSelectedListingTypes(pendingTable.get(TABLE_KEYS.LISTING_TYPE)).filter((t) =>
|
|
142
|
+
const pendingTypes = parseSelectedListingTypes(pendingTable.get(TABLE_KEYS.LISTING_TYPE)).filter((t) => enabledSpan.includes(t));
|
|
113
143
|
const dedicatedPage = dedicatedPageFor(pendingTypes);
|
|
114
|
-
const listingTypeOptions =
|
|
144
|
+
const listingTypeOptions = enabledTypeTabs.map((t) => ({ value: t.id, label: t.label }));
|
|
115
145
|
const openFilters = useCallback(() => {
|
|
116
146
|
onFilterReset();
|
|
117
147
|
setFilterOpen(true);
|
|
@@ -10,6 +10,9 @@ import { GENERIC_PRODUCT_LISTING_TYPES } from "../constants/listing-tabs.js";
|
|
|
10
10
|
import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js";
|
|
11
11
|
import { listPublicProducts, parsePublicProductParams, defaultAvailabilityForListingTypes, } from "../../../_internal/server/features/products/list-public.js";
|
|
12
12
|
import { parseSelectedListingTypes } from "../utils/listing-type.js";
|
|
13
|
+
import { isListingTypeEnabled } from "../../../_internal/shared/listing-types/feature-flags.js";
|
|
14
|
+
import { siteSettingsRepository } from "../../../repositories/index.js";
|
|
15
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
13
16
|
const DEFAULT_PAGE_SIZE = 24;
|
|
14
17
|
const DEFAULT_SORT = sortBy(PRODUCT_FIELDS.CREATED_AT);
|
|
15
18
|
export async function ProductsIndexPageView({ searchParams = {} }) {
|
|
@@ -47,11 +50,41 @@ export async function ProductsIndexPageView({ searchParams = {} }) {
|
|
|
47
50
|
const effectiveTypes = selectedTypes.length > 0 ? selectedTypes : GENERIC_PRODUCT_LISTING_TYPES;
|
|
48
51
|
// See ArtStickersListView — one shared filter implementation with
|
|
49
52
|
// /api/products, so SSR and the client refetch agree by construction.
|
|
50
|
-
const products = await
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
const [products, settings] = await Promise.all([
|
|
54
|
+
listPublicProducts(parsePublicProductParams(searchParams, {
|
|
55
|
+
listingTypes: GENERIC_PRODUCT_LISTING_TYPES,
|
|
56
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
|
57
|
+
sorts: DEFAULT_SORT,
|
|
58
|
+
...defaultAvailabilityForListingTypes(effectiveTypes),
|
|
59
|
+
})),
|
|
60
|
+
/*
|
|
61
|
+
* Read in parallel with the listing — the banner below is the only
|
|
62
|
+
* consumer, and serialising a settings read in front of the page's main
|
|
63
|
+
* query would spend a round trip against Rule #6's budget for one link.
|
|
64
|
+
*
|
|
65
|
+
* 🛑 `safeRead`, so a settings failure hides the banner rather than
|
|
66
|
+
* failing the catalogue. Auctions have their own nav entry and their own
|
|
67
|
+
* page; the banner is a shortcut, and a shortcut is the correct thing to
|
|
68
|
+
* lose when its gate cannot be evaluated.
|
|
69
|
+
*/
|
|
70
|
+
safeRead(() => siteSettingsRepository.findById("global"), {
|
|
71
|
+
route: "/products",
|
|
72
|
+
key: "products.siteSettings",
|
|
73
|
+
fallback: null,
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
76
|
+
/*
|
|
77
|
+
* 🛑 Auctions are a LISTING TYPE an admin can switch off, and this banner
|
|
78
|
+
* advertised them unconditionally.
|
|
79
|
+
*
|
|
80
|
+
* Every other auction entry point already respects the flag — the navbar
|
|
81
|
+
* child (filtered by key in `LayoutShellClient`), the footer link
|
|
82
|
+
* (`FOOTER_LINK_LISTING_TYPES`), the store tabs (`tabs.ts`), the
|
|
83
|
+
* category/brand tabs, and the homepage section (`gatedListing`). This was
|
|
84
|
+
* the one surface left promoting a page that renders nothing on a site with
|
|
85
|
+
* auctions disabled, which is Root Cause #37's dead-link shape reached from
|
|
86
|
+
* the other direction.
|
|
87
|
+
*/
|
|
88
|
+
const showAuctionsBanner = isListingTypeEnabled("auction", settings);
|
|
89
|
+
return (_jsxs(Main, { children: [_jsx(Section, { border: "bottom-subtle", paddingY: "b-md", padding: "t-xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, size: "3xl", weight: "bold", color: "primary", children: "Products" }), _jsx(Text, { className: "mt-1", color: "muted", size: "sm", children: "Discover amazing products and deals" }), showAuctionsBanner && (_jsx(Div, { className: "mt-3", children: _jsx(TextLink, { variant: "bare", href: String(ROUTES.PUBLIC.AUCTIONS), rounded: "full", paddingX: "sm", paddingY: "2xs", size: "xs", weight: "medium", layout: "inline-flex", align: "center", gap: "sm", className: "border border-primary/30 bg-primary/10 text-primary-700 dark:text-primary-400 hover:bg-primary/15 transition-colors", children: "\uD83C\uDFF7\uFE0F Looking for unique deals? Browse Auctions \u2192" }) }))] }) }), _jsxs(Container, { size: "xl", padding: "x-md", children: [_jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-4 mt-4" }), _jsx(ProductsIndexListing, { initialData: products ?? undefined }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] })] }));
|
|
57
90
|
}
|
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
* `loginResponse` requires — the rest are ignored, as Zod ignores unknown
|
|
13
13
|
* keys by default.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* ✅ ALSO VERIFIED (2026-10-20): `GET /courier/serviceability/` from pickup
|
|
16
|
+
* `413001`. HTTP 200, and `data.available_courier_companies` came back with
|
|
17
|
+
* 10-11 entries for Mumbai, Bengaluru and Delhi carrying `courier_name`,
|
|
18
|
+
* `rate` and `estimated_delivery_days` — so `serviceabilityResponse` below
|
|
19
|
+
* is read off a real payload, not inferred. It is a read-only quote: no
|
|
20
|
+
* shipment is created and nothing is booked.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 NOT VERIFIED: order creation, AWB assignment, tracking and label
|
|
23
|
+
* generation. Each MUTATES or bills a real carrier account — creating an
|
|
24
|
+
* order can schedule a pickup, an AWB can consume an allocation, a cancel
|
|
25
|
+
* may still bill — so none was called, and none is discoverable without
|
|
26
|
+
* incurring it. Their field names below are claims from the published docs,
|
|
27
|
+
* not facts read off a payload.
|
|
19
28
|
*
|
|
20
29
|
* That asymmetry is the reason every response is parsed through Zod rather
|
|
21
30
|
* than cast: an unverified contract that is wrong surfaces as a named error at
|