@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.
Files changed (34) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.js +61 -2
  3. package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
  4. package/dist/_internal/shared/features/checkout/config.js +1 -1
  5. package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
  6. package/dist/_internal/shared/features/site-settings/fees.js +1 -1
  7. package/dist/_internal/shared/fees/calculator.d.ts +35 -3
  8. package/dist/_internal/shared/fees/calculator.js +34 -4
  9. package/dist/errors/error-codes.d.ts +15 -0
  10. package/dist/errors/error-codes.js +15 -0
  11. package/dist/errors/index.d.ts +1 -1
  12. package/dist/errors/index.js +1 -1
  13. package/dist/features/account/components/UserBidsView.js +4 -2
  14. package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
  15. package/dist/features/admin/schemas/firestore.d.ts +37 -2
  16. package/dist/features/admin/schemas/firestore.js +12 -1
  17. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  18. package/dist/features/checkout/schemas/firestore.d.ts +12 -1
  19. package/dist/features/layout/NavbarLayout.js +139 -32
  20. package/dist/features/orders/schemas/firestore.d.ts +1 -1
  21. package/dist/features/products/components/ProductDetailPageView.js +2 -25
  22. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
  23. package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
  24. package/dist/features/products/components/ProductsIndexListing.js +35 -5
  25. package/dist/features/products/components/ProductsIndexPageView.js +40 -7
  26. package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
  27. package/dist/providers/shipping-shiprocket/index.js +34 -10
  28. package/dist/seed/faq-seed-data.js +1 -1
  29. package/dist/seed/site-settings-seed-data.js +1 -1
  30. package/dist/seo/json-ld.d.ts +8 -1
  31. package/dist/seo/json-ld.js +39 -2
  32. package/dist/styles.css +1 -1
  33. package/dist/tailwind-utilities.css +1 -1
  34. 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
- * 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.
@@ -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: max(codHandlingFeeMin, subtotal × codHandlingFeePercent / 100). Only set when paymentMethod === "cod". */
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 { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
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(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: title || String(product.slug ?? product.id), storeId: product.storeId ? String(product.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(product.id))), onClone: async () => {
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) => listingTypes.includes(t));
86
- const effectiveTypes = (selectedTypes.length > 0 ? selectedTypes : listingTypes);
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) => listingTypes.includes(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 = typeTabs.map((t) => ({ value: t.id, label: t.label }));
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 listPublicProducts(parsePublicProductParams(searchParams, {
51
- listingTypes: GENERIC_PRODUCT_LISTING_TYPES,
52
- pageSize: DEFAULT_PAGE_SIZE,
53
- sorts: DEFAULT_SORT,
54
- ...defaultAvailabilityForListingTypes(effectiveTypes),
55
- }));
56
- 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" }), _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" })] })] }));
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
- * 🛑 NOT VERIFIED: every other endpoint. Order creation, AWB assignment,
16
- * tracking, serviceability and label generation all MUTATE or bill against
17
- * a real carrier account, so none was called. Their field names below are
18
- * claims taken from the published docs, not facts read off a payload.
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