@mohasinac/appkit 2.7.39 → 2.7.40

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.
@@ -3,6 +3,11 @@ export interface UserNavItem {
3
3
  href: string;
4
4
  label: string;
5
5
  icon?: React.ReactNode;
6
+ /** When set, intercepts navigation with a confirmation prompt. */
7
+ confirm?: {
8
+ title?: string;
9
+ message: string;
10
+ };
6
11
  }
7
12
  export interface UserNavGroup {
8
13
  title: string;
@@ -10,7 +10,17 @@ function isNavItemActive(item, activeHref) {
10
10
  return activeHref === item.href || activeHref.startsWith(item.href + "/");
11
11
  }
12
12
  function NavLink({ item, isActive, onClick }) {
13
- return (_jsxs(Link, { href: item.href, onClick: onClick, className: `flex items-center gap-2.5 rounded-lg px-3 py-2 text-[0.8125rem] font-medium leading-tight transition-colors ${isActive
13
+ const handleClick = (e) => {
14
+ if (item.confirm && typeof window !== "undefined") {
15
+ const ok = window.confirm(item.confirm.message);
16
+ if (!ok) {
17
+ e.preventDefault();
18
+ return;
19
+ }
20
+ }
21
+ onClick?.();
22
+ };
23
+ return (_jsxs(Link, { href: item.href, onClick: handleClick, className: `flex items-center gap-2.5 rounded-lg px-3 py-2 text-[0.8125rem] font-medium leading-tight transition-colors ${isActive
14
24
  ? "bg-primary-50 dark:bg-primary-900/25 text-primary-700 dark:text-primary-300"
15
25
  : "text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-slate-800 hover:text-zinc-900 dark:hover:text-zinc-100"}`, children: [item.icon && _jsx(Span, { className: "shrink-0 text-base opacity-70", children: item.icon }), _jsx(Span, { className: "flex-1 truncate", children: item.label })] }));
16
26
  }
@@ -41,13 +41,50 @@ export async function placeBid(userId, userEmail, input) {
41
41
  const baseBid = (product.currentBid ?? 0) > 0
42
42
  ? product.currentBid
43
43
  : (product.startingBid ?? product.price);
44
- if (bidAmount <= baseBid) {
44
+ const minIncrement = product.minBidIncrement ?? 100;
45
+ // Proxy-bid semantics (eBay style): the buyer's `bidAmount` is treated as
46
+ // their **maximum** they're willing to pay; the visible price only steps up
47
+ // in `minIncrement` units as needed to stay ahead of the previous high bid.
48
+ // If `autoMaxBid` is supplied explicitly it takes precedence over bidAmount
49
+ // as the cap (lets clients separate desired-step from secret-cap).
50
+ const newCap = Math.max(bidAmount, autoMaxBid ?? bidAmount);
51
+ if (newCap <= baseBid) {
45
52
  throw new ValidationError(ERROR_MESSAGES.BID.BID_TOO_LOW, { code: BID_ERROR_CODES.TOO_LOW });
46
53
  }
47
- const minIncrement = product.minBidIncrement ?? 100;
48
- if (bidAmount < baseBid + minIncrement) {
54
+ if (newCap < baseBid + minIncrement) {
49
55
  throw new ValidationError(ERROR_MESSAGES.BID.INCREMENT_TOO_LOW, { code: BID_ERROR_CODES.INCREMENT_VIOLATED });
50
56
  }
57
+ const previousWinner = await bidRepository
58
+ .getWinningBid(productId)
59
+ .catch(() => null);
60
+ const prevCap = previousWinner?.data
61
+ ? (previousWinner.data.autoMaxBid ?? previousWinner.data.bidAmount ?? baseBid)
62
+ : baseBid;
63
+ const prevVisible = previousWinner?.data?.bidAmount ?? baseBid;
64
+ const sameBidder = previousWinner?.data?.userId === userId;
65
+ // Decide who wins after this submission and what the visible price becomes.
66
+ // Tie goes to the existing leader (first-bidder advantage), matching eBay.
67
+ let newBidWins;
68
+ let visibleBid;
69
+ let bumpedPreviousVisible = null;
70
+ if (sameBidder) {
71
+ // Raising your own cap. You stay winning; visible stays put.
72
+ newBidWins = true;
73
+ visibleBid = prevVisible;
74
+ }
75
+ else if (!previousWinner || newCap > prevCap) {
76
+ newBidWins = true;
77
+ const target = Math.max(prevCap + minIncrement, baseBid + minIncrement);
78
+ visibleBid = Math.min(newCap, target);
79
+ }
80
+ else {
81
+ // newCap <= prevCap → previous winner keeps it. Bump their visible up to
82
+ // outpace the new bid (capped at their own max).
83
+ newBidWins = false;
84
+ const target = newCap + minIncrement;
85
+ visibleBid = newCap;
86
+ bumpedPreviousVisible = Math.min(prevCap, target);
87
+ }
51
88
  const profile = await userRepository.findById(userId);
52
89
  const bid = await bidRepository.create({
53
90
  productId,
@@ -55,41 +92,44 @@ export async function placeBid(userId, userEmail, input) {
55
92
  userId,
56
93
  userName: profile?.displayName ?? userEmail ?? "Anonymous",
57
94
  userEmail: profile?.email ?? userEmail ?? "",
58
- bidAmount,
95
+ bidAmount: visibleBid,
59
96
  currency: product.currency || getDefaultCurrency(),
60
97
  bidDate: new Date(),
61
- ...(autoMaxBid ? { autoMaxBid } : {}),
98
+ autoMaxBid: newCap,
62
99
  });
63
- // Only flip the previous winning bid (if any) — no need to rewrite every
64
- // historical bid each time, which previously exceeded Firestore's 500-op
65
- // batch cap on auctions with hundreds of bids ("Batch write failed").
66
- const previousWinner = await bidRepository
67
- .getWinningBid(productId)
68
- .catch(() => null);
69
100
  await unitOfWork.runBatch((batch) => {
70
101
  if (previousWinner && previousWinner.data.id !== bid.id) {
71
- unitOfWork.bids.updateInBatch(batch, previousWinner.data.id, {
72
- isWinning: false,
73
- status: "outbid",
74
- });
102
+ if (newBidWins) {
103
+ unitOfWork.bids.updateInBatch(batch, previousWinner.data.id, {
104
+ isWinning: false,
105
+ status: "outbid",
106
+ });
107
+ }
108
+ else if (bumpedPreviousVisible !== null && bumpedPreviousVisible !== prevVisible) {
109
+ unitOfWork.bids.updateInBatch(batch, previousWinner.data.id, {
110
+ bidAmount: bumpedPreviousVisible,
111
+ });
112
+ }
75
113
  }
76
114
  unitOfWork.bids.updateInBatch(batch, bid.id, {
77
- isWinning: true,
78
- status: "active",
115
+ isWinning: newBidWins,
116
+ status: newBidWins ? "active" : "outbid",
79
117
  });
118
+ const finalCurrentBid = newBidWins ? visibleBid : (bumpedPreviousVisible ?? prevVisible);
80
119
  unitOfWork.products.updateInBatch(batch, productId, {
81
- currentBid: bidAmount,
120
+ currentBid: finalCurrentBid,
82
121
  bidCount: increment(1),
83
- leadingBidderId: userId,
122
+ leadingBidderId: newBidWins ? userId : (previousWinner?.data?.userId ?? userId),
84
123
  bidsHaveStarted: true,
85
124
  });
86
125
  });
126
+ const finalVisibleForRtdb = newBidWins ? visibleBid : (bumpedPreviousVisible ?? prevVisible);
87
127
  try {
88
128
  const rtdb = getAdminRealtimeDb();
89
129
  await rtdb.ref(`/auction-bids/${productId}`).set({
90
- currentBid: bidAmount,
130
+ currentBid: finalVisibleForRtdb,
91
131
  lastBid: {
92
- amount: bidAmount,
132
+ amount: finalVisibleForRtdb,
93
133
  bidderName: "Bidder",
94
134
  timestamp: Date.now(),
95
135
  },
@@ -106,7 +146,9 @@ export async function placeBid(userId, userEmail, input) {
106
146
  bidId: bid.id,
107
147
  productId,
108
148
  userId,
109
- bidAmount,
149
+ bidAmount: visibleBid,
150
+ maxProxyBid: newCap,
151
+ winning: newBidWins,
110
152
  });
111
153
  return { bid };
112
154
  }
@@ -16,3 +16,13 @@ export interface PlaceBidFormClientProps {
16
16
  onPlaceBid: (input: PlaceBidInput) => Promise<unknown>;
17
17
  }
18
18
  export declare function PlaceBidFormClient({ productId, currentBid, startingBid, minBidIncrement, currency, isEnded, buyNowPrice, bidCount, tags, onPlaceBid, }: PlaceBidFormClientProps): import("react/jsx-runtime").JSX.Element;
19
+ /**
20
+ * `PlaceBidModalButton` — opens a modal that hosts the same PlaceBidFormClient.
21
+ *
22
+ * Use this on listing-detail pages so buyers explicitly opt-in to the bid
23
+ * surface instead of always seeing the card inline.
24
+ */
25
+ export declare function PlaceBidModalButton(props: PlaceBidFormClientProps & {
26
+ triggerLabel?: string;
27
+ triggerClassName?: string;
28
+ }): import("react/jsx-runtime").JSX.Element;
@@ -1,9 +1,9 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useTransition } from "react";
4
4
  import { formatCurrency } from "../../../utils/number.formatter";
5
5
  import { isAuthError } from "../../../utils/auth-error";
6
- import { Button, Div, Input, LoginRequiredModal, Row, Span, Stack, Text } from "../../../ui";
6
+ import { Button, Div, Input, LoginRequiredModal, Modal, Row, Span, Stack, Text } from "../../../ui";
7
7
  const BID_ERROR_DISPLAY = {
8
8
  BID_AUCTION_ENDED: "This auction has closed. No more bids are accepted.",
9
9
  BID_AMOUNT_TOO_LOW: "Your bid must exceed the current winning bid.",
@@ -54,3 +54,14 @@ export function PlaceBidFormClient({ productId, currentBid, startingBid, minBidI
54
54
  }
55
55
  return (_jsxs(Div, { className: "rounded-xl border border-zinc-100 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/60 p-5 space-y-4", children: [_jsxs(Div, { className: "space-y-1", children: [_jsxs(Row, { justify: "between", align: "center", children: [_jsx(Text, { className: "text-xs text-zinc-500", children: "Current bid" }), _jsx(Text, { className: "text-xs text-zinc-500", children: "Starting bid" })] }), _jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(Span, { className: "text-xl font-bold text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsx(Span, { className: "text-sm text-zinc-500", children: formatCurrency(startingBid, currency) })] }), _jsxs(Text, { className: "text-xs text-zinc-400 dark:text-zinc-500", children: [bidCount, " ", bidCount === 1 ? "bid" : "bids", " \u00B7 min increment", " ", formatCurrency(minBidIncrement, currency)] })] }), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to place a bid. Please log in or create an account to continue." }), _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Input, { type: "number", value: bidAmount, onChange: (e) => setBidAmount(e.target.value), placeholder: `At least ${formatCurrency(minBid, currency)}`, min: minBid, step: minBidIncrement, "aria-label": "Your bid amount", disabled: isEnded || isPending }), error && (_jsx(Text, { className: "text-xs text-red-600 dark:text-red-400", children: error })), success && (_jsx(Text, { className: "text-xs text-emerald-600 dark:text-emerald-400", children: "\u2713 Bid placed successfully!" })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded || isPending, type: "submit", children: isPending ? "Placing Bid…" : isEnded ? "Auction Ended" : "Place Bid" }), buyNowPrice !== null && !isEnded && (_jsxs(Button, { variant: "secondary", size: "md", className: "w-full", type: "button", children: ["Buy Now \u2014 ", formatCurrency(buyNowPrice, currency)] }))] }) }), tags.length > 0 && (_jsx(Div, { className: "border-t border-zinc-200 dark:border-zinc-700 pt-4", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { className: "rounded-full bg-zinc-100 dark:bg-zinc-800 px-2.5 py-1 text-xs text-zinc-600 dark:text-zinc-300", children: tag }, tag))) }) }))] }));
56
56
  }
57
+ /**
58
+ * `PlaceBidModalButton` — opens a modal that hosts the same PlaceBidFormClient.
59
+ *
60
+ * Use this on listing-detail pages so buyers explicitly opt-in to the bid
61
+ * surface instead of always seeing the card inline.
62
+ */
63
+ export function PlaceBidModalButton(props) {
64
+ const { triggerLabel = "Place a bid", triggerClassName = "", ...formProps } = props;
65
+ const [open, setOpen] = useState(false);
66
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", size: "md", className: triggerClassName, disabled: props.isEnded, onClick: () => setOpen(true), children: props.isEnded ? "Auction Ended" : triggerLabel }), _jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...formProps }) })] }));
67
+ }
@@ -1,5 +1,5 @@
1
1
  import type { TitleBarLayoutProps } from "./TitleBarLayout";
2
- export interface TitleBarProps extends Omit<TitleBarLayoutProps, "cartCount" | "wishlistCount" | "hasDashboardNav" | "onToggleDashboardNav"> {
2
+ export interface TitleBarProps extends Omit<TitleBarLayoutProps, "cartCount" | "wishlistCount" | "unreadNotificationCount" | "hasDashboardNav" | "onToggleDashboardNav"> {
3
3
  /** Auth UID of the current user — used for wishlist merge-on-login and sync. */
4
4
  userId?: string | null;
5
5
  /**
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useCartCount } from "../cart/hooks/useCartCount";
3
3
  import { useWishlistCount } from "../wishlist/hooks/useWishlistCount";
4
+ import { useNotifications } from "../account/hooks/useNotifications";
4
5
  import { useDashboardNav } from "./DashboardNavContext";
5
6
  import { TitleBarLayout } from "./TitleBarLayout";
6
7
  /**
@@ -15,8 +16,9 @@ import { TitleBarLayout } from "./TitleBarLayout";
15
16
  export function TitleBar({ suppressDashboardNav, onBeforeToggleDashboardNav, userId, ...rest }) {
16
17
  const cartCount = useCartCount(!!rest.user);
17
18
  const wishlistCount = useWishlistCount(userId);
19
+ const { unreadCount } = useNotifications(1);
18
20
  const { hasNav: hasDashboardNav, toggleNav: toggleDashboardNav } = useDashboardNav();
19
- return (_jsx(TitleBarLayout, { ...rest, cartCount: cartCount, wishlistCount: wishlistCount, hasDashboardNav: suppressDashboardNav ? false : hasDashboardNav, onToggleDashboardNav: suppressDashboardNav
21
+ return (_jsx(TitleBarLayout, { ...rest, cartCount: cartCount, wishlistCount: wishlistCount, unreadNotificationCount: rest.user ? unreadCount : 0, hasDashboardNav: suppressDashboardNav ? false : hasDashboardNav, onToggleDashboardNav: suppressDashboardNav
20
22
  ? undefined
21
23
  : () => {
22
24
  onBeforeToggleDashboardNav?.();
@@ -25,6 +25,8 @@ export interface TitleBarLayoutProps {
25
25
  cartHref?: string;
26
26
  cartCount?: number;
27
27
  profileHref?: string;
28
+ /** Unread notification count to show as a badge on the profile icon. */
29
+ unreadNotificationCount?: number;
28
30
  loginHref?: string;
29
31
  registerHref?: string;
30
32
  user?: TitleBarUser | null;
@@ -56,4 +58,4 @@ export interface TitleBarLayoutProps {
56
58
  *
57
59
  * Receives all domain data as props — zero domain imports.
58
60
  */
59
- export declare function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen: _searchOpen, brandName, brandShortName: _brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount, cartHref, cartCount, profileHref, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark, onToggleTheme, hasDashboardNav: _hasDashboardNav, onToggleDashboardNav: _onToggleDashboardNav, hideSidebarToggle, id, className, }: TitleBarLayoutProps): import("react/jsx-runtime").JSX.Element;
61
+ export declare function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen: _searchOpen, brandName, brandShortName: _brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount, cartHref, cartCount, profileHref, unreadNotificationCount, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark, onToggleTheme, hasDashboardNav: _hasDashboardNav, onToggleDashboardNav: _onToggleDashboardNav, hideSidebarToggle, id, className, }: TitleBarLayoutProps): import("react/jsx-runtime").JSX.Element;
@@ -14,7 +14,7 @@ const countBadge = "absolute -top-0.5 -right-0.5 flex items-center justify-cente
14
14
  *
15
15
  * Receives all domain data as props — zero domain imports.
16
16
  */
17
- export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen: _searchOpen, brandName, brandShortName: _brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount = 0, cartHref, cartCount = 0, profileHref, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark = false, onToggleTheme, hasDashboardNav: _hasDashboardNav, onToggleDashboardNav: _onToggleDashboardNav, hideSidebarToggle = false, id = "titlebar", className = "", }) {
17
+ export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, searchOpen: _searchOpen, brandName, brandShortName: _brandShortName, siteLogoUrl, logoHref, promotionsHref, compareHref, wishlistHref, wishlistCount = 0, cartHref, cartCount = 0, profileHref, unreadNotificationCount = 0, loginHref, registerHref, user, notificationSlot, devSlot, navSlot, promoStripText, isDark = false, onToggleTheme, hasDashboardNav: _hasDashboardNav, onToggleDashboardNav: _onToggleDashboardNav, hideSidebarToggle = false, id = "titlebar", className = "", }) {
18
18
  // ── Element builders ────────────────────────────────────────────────────────
19
19
  const promotionsEl = promotionsHref ? (_jsxs(Link, { href: promotionsHref, "aria-label": "Today's deals", className: "flex items-center gap-1 px-3 py-1 rounded-full text-xs font-bold bg-primary-100 text-primary-700 dark:bg-secondary-900/40 dark:text-secondary-400 hover:bg-primary-200 dark:hover:bg-secondary-900/60 transition-colors border border-primary-200/60 dark:border-secondary-700/40", children: [_jsx("svg", { className: "w-3 h-3", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: _jsx("path", { d: "M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.37.86.58 1.41.58.55 0 1.05-.21 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z" }) }), _jsx(Span, { className: "hidden sm:inline", children: "Today's Deals" })] })) : null;
20
20
  const themeBtn = onToggleTheme ? (_jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": isDark ? "Switch to light mode" : "Switch to dark mode", onClick: onToggleTheme, className: iconBtn, children: isDark ? (_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 3v1m0 16v1m8.66-9h-1M4.34 12h-1m15.07-6.07-.71.71M6.34 17.66l-.71.71m12.73 0-.71-.71M6.34 6.34l-.71-.71M12 5a7 7 0 1 0 0 14A7 7 0 0 0 12 5z" }) })) : (_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79z" }) })) })) : null;
@@ -24,9 +24,9 @@ export function TitleBarLayout({ onToggleSidebar, sidebarOpen, onSearchToggle, s
24
24
  const searchBtn = (_jsx(Button, { type: "button", variant: "ghost", size: "sm", "aria-label": "Search", onClick: onSearchToggle, className: iconBtn, children: _jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" }) }) }));
25
25
  const wishlistEl = wishlistHref ? (_jsxs(Link, { href: wishlistHref, "aria-label": `Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`, className: `relative ${iconBtn}`, children: [_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M4.318 6.318a4.5 4.5 0 0 0 0 6.364L12 20.364l7.682-7.682a4.5 4.5 0 0 0-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 0 0-6.364 0z" }) }), wishlistCount > 0 && (_jsx(Span, { className: countBadge, children: wishlistCount > 9 ? "9+" : wishlistCount }))] })) : null;
26
26
  const cartEl = cartHref ? (_jsxs(Link, { href: cartHref, "aria-label": `Cart${cartCount > 0 ? `, ${cartCount} items` : ""}`, className: `relative ${iconBtn}`, children: [_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" }) }), cartCount > 0 && (_jsx(Span, { className: countBadge, children: cartCount > 9 ? "9+" : cartCount }))] })) : null;
27
- const profileEl = profileHref ? (_jsx(Link, { href: profileHref, "aria-label": user ? `Profile — ${user.displayName ?? user.email}` : "Sign in", className: iconBtn, children: user?.photoURL ? (
28
- // eslint-disable-next-line @next/next/no-img-element
29
- _jsx("img", { src: user.photoURL, alt: user.displayName ?? "Profile", width: 28, height: 28, className: "w-7 h-7 rounded-full object-cover" })) : (_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0zM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632z" }) })) })) : null;
27
+ const profileEl = profileHref ? (_jsxs(Link, { href: profileHref, "aria-label": user ? `Profile — ${user.displayName ?? user.email}${unreadNotificationCount > 0 ? `, ${unreadNotificationCount} unread alerts` : ""}` : "Sign in", className: `relative ${iconBtn}`, children: [user?.photoURL ? (
28
+ // eslint-disable-next-line @next/next/no-img-element
29
+ _jsx("img", { src: user.photoURL, alt: user.displayName ?? "Profile", width: 28, height: 28, loading: "lazy", className: "w-7 h-7 rounded-full object-cover" })) : (_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0zM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632z" }) })), unreadNotificationCount > 0 && (_jsx(Span, { className: countBadge, children: unreadNotificationCount > 99 ? "99+" : unreadNotificationCount }))] })) : null;
30
30
  const authButtonsEl = !user && (loginHref || registerHref) ? (_jsxs(Row, { gap: "xs", className: "hidden lg:flex items-center", children: [loginHref && (_jsx(Link, { href: loginHref, className: "px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:text-primary-700 dark:hover:text-secondary-400 transition-colors rounded-lg hover:bg-primary-50 dark:hover:bg-slate-800", children: "Sign in" })), registerHref && (_jsx(Link, { href: registerHref, className: "px-3 py-1.5 text-sm font-semibold rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors btn-glow shadow-sm", children: "Register" }))] })) : null;
31
31
  const hasTb2 = !!(wishlistEl || cartEl || profileEl);
32
32
  // ── Render ───────────────────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useTransition } from "react";
4
- import { Button, Div, Input, LoginRequiredModal, Span, Text } from "../../../ui";
4
+ import { Button, Div, Input, LoginRequiredModal, Modal, Span, Stack, Text } from "../../../ui";
5
5
  import { isAuthError } from "../../../utils/auth-error";
6
6
  import { formatCurrency } from "../../../utils/number.formatter";
7
7
  function isActiveOfferError(msg) {
@@ -72,11 +72,6 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
72
72
  if (state === "pending") {
73
73
  return (_jsxs(Div, { className: `rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4 text-center space-y-1 ${className}`, children: [_jsx(Span, { className: "text-lg", children: "\u23F3" }), _jsx(Text, { className: "text-sm font-medium text-amber-800 dark:text-amber-300", children: "Offer Pending" }), _jsx(Text, { className: "text-xs text-amber-700 dark:text-amber-400", children: "You already have an offer on this item. Check My Offers for updates." })] }));
74
74
  }
75
- if (state === "confirm") {
76
- return (_jsxs(Div, { className: `rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/60 p-4 space-y-3 ${className}`, children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-sm font-medium text-zinc-800 dark:text-zinc-200", children: "Make an Offer" }), _jsxs(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400 mt-0.5", children: ["Listed at ", fmt(listedPrice), " \u00B7 Minimum offer: ", fmt(minOffer)] })] }), _jsxs(Div, { className: "space-y-1", children: [_jsx(Text, { className: "text-xs font-medium text-zinc-600 dark:text-zinc-400", children: "Your offer amount" }), _jsx(Input, { type: "number", value: String(offerAmount), onChange: (e) => handleAmountChange(e.target.value), min: minOffer, max: listedPrice - 1, step: 1, "aria-label": "Offer amount" }), _jsxs(Text, { className: "text-xs text-zinc-400 dark:text-zinc-500", children: ["Must be between ", fmt(minOffer), " and ", fmt(listedPrice - 1)] })] }), _jsxs(Div, { className: "space-y-1", children: [_jsx(Text, { className: "text-xs font-medium text-zinc-600 dark:text-zinc-400", children: "Note to seller (optional)" }), _jsx(Input, { type: "text", value: buyerNote, onChange: (e) => setBuyerNote(e.target.value), placeholder: "E.g. Bundle deal, long-time fan...", maxLength: 300, "aria-label": "Note to seller" })] }), _jsx(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: "The seller will accept, decline, or suggest a counter price." }), _jsxs(Div, { className: "flex gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "flex-1", onClick: handleCancel, disabled: isPending, children: "Cancel" }), _jsx(Button, { variant: "secondary", size: "sm", className: "flex-1", onClick: handleSubmit, disabled: isPending || offerAmount < minOffer, children: isPending ? "Sending…" : `Send ${fmt(offerAmount)}` })] })] }));
77
- }
78
- if (state === "error") {
79
- return (_jsxs(Div, { className: `rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4 space-y-2 ${className}`, children: [_jsx(Text, { className: "text-xs text-red-600 dark:text-red-400 text-center", children: errorMsg }), _jsxs(Div, { className: "flex gap-2", children: [_jsx(Button, { variant: "ghost", size: "sm", className: "flex-1", onClick: handleCancel, children: "Cancel" }), _jsx(Button, { variant: "secondary", size: "sm", className: "flex-1", onClick: () => setState("confirm"), children: "Try Again" })] })] }));
80
- }
81
- return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", className: `w-full border border-zinc-300 dark:border-zinc-600 ${className}`, onClick: handleOpenConfirm, children: "Make Offer" })] }));
75
+ const modalOpen = state === "confirm" || state === "loading" || state === "error";
76
+ return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to make an offer. Please log in or create an account to continue." }), _jsx(Button, { variant: "ghost", size: "md", className: `w-full border border-zinc-300 dark:border-zinc-600 ${className}`, onClick: handleOpenConfirm, children: "Make Offer" }), _jsx(Modal, { isOpen: modalOpen, onClose: handleCancel, size: "md", title: "Make an offer", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: handleCancel, disabled: isPending, children: "Cancel" }), _jsx(Button, { variant: "primary", size: "sm", onClick: handleSubmit, disabled: isPending || offerAmount < minOffer, children: isPending ? "Sending…" : `Send offer of ${fmt(offerAmount)}` })] }), children: _jsxs(Stack, { gap: "md", children: [_jsxs(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: ["Listed at ", fmt(listedPrice), " \u00B7 Minimum offer: ", fmt(minOffer)] }), _jsxs(Div, { className: "space-y-1", children: [_jsx(Text, { className: "text-xs font-medium text-zinc-600 dark:text-zinc-400", children: "Your offer amount" }), _jsx(Input, { type: "number", value: String(offerAmount), onChange: (e) => handleAmountChange(e.target.value), min: minOffer, max: listedPrice - 1, step: 1, "aria-label": "Offer amount" }), _jsxs(Text, { className: "text-xs text-zinc-400 dark:text-zinc-500", children: ["Must be between ", fmt(minOffer), " and ", fmt(listedPrice - 1)] })] }), _jsxs(Div, { className: "space-y-1", children: [_jsx(Text, { className: "text-xs font-medium text-zinc-600 dark:text-zinc-400", children: "Note to seller (optional)" }), _jsx(Input, { type: "text", value: buyerNote, onChange: (e) => setBuyerNote(e.target.value), placeholder: "E.g. Bundle deal, long-time fan\u2026", maxLength: 300, "aria-label": "Note to seller" })] }), _jsx(Text, { className: "text-xs text-zinc-500 dark:text-zinc-400", children: "The seller will accept, decline, or suggest a counter price." }), state === "error" && (_jsx(Text, { className: "text-xs text-red-600 dark:text-red-400", children: errorMsg }))] }) })] }));
82
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "2.7.39",
3
+ "version": "2.7.40",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"