@mohasinac/appkit 2.7.9 → 2.7.10

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 (35) hide show
  1. package/dist/_internal/shared/config/index.d.ts +1 -1
  2. package/dist/_internal/shared/config/schema.d.ts +95 -1
  3. package/dist/client-entry.d.ts +1 -1
  4. package/dist/client.d.ts +3 -0
  5. package/dist/client.js +5 -0
  6. package/dist/features/account/components/UserOffersPanel.js +17 -5
  7. package/dist/features/auctions/components/AuctionDetailPageView.js +17 -1
  8. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -5
  9. package/dist/features/categories/components/BundleBuyNowCta.js +13 -6
  10. package/dist/features/events/components/EventPollWidget.js +7 -1
  11. package/dist/features/events/components/SpinWheelView.js +12 -5
  12. package/dist/features/pre-orders/components/PreOrderActionsClient.js +10 -3
  13. package/dist/features/products/components/MakeOfferButton.js +16 -8
  14. package/dist/features/products/components/PrizeRevealModal.js +18 -5
  15. package/dist/features/products/components/ProductDetailPageView.js +23 -1
  16. package/dist/features/seller/components/SellerOffersPanel.js +17 -5
  17. package/dist/features/wishlist/components/WishlistToggleButton.js +26 -12
  18. package/dist/http/api-handler.js +7 -1
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.js +2 -0
  21. package/dist/server.d.ts +1 -0
  22. package/dist/server.js +2 -0
  23. package/dist/tailwind-utilities.css +1 -1
  24. package/dist/ui/components/BaseListingCard.js +8 -6
  25. package/dist/ui/components/Button.style.css +8 -5
  26. package/dist/ui/components/ListingLayout.style.css +19 -9
  27. package/dist/ui/components/LoginRequiredModal.d.ts +9 -0
  28. package/dist/ui/components/LoginRequiredModal.js +11 -0
  29. package/dist/ui/index.d.ts +2 -0
  30. package/dist/ui/index.js +1 -0
  31. package/dist/utils/auth-error.d.ts +8 -0
  32. package/dist/utils/auth-error.js +20 -0
  33. package/dist/utils/index.d.ts +1 -0
  34. package/dist/utils/index.js +1 -0
  35. package/package.json +1 -1
@@ -1 +1 @@
1
- export type { AppkitConfig, AppkitSmokeRoute, AppkitThemeProbeRoute, AppkitAuthFixture } from "./schema";
1
+ export type { AppkitConfig, AppkitSmokeRoute, AppkitThemeProbeRoute, AppkitAuthFixture, AppkitFirebaseConfig, AppkitFirebaseExtensions, AppkitVercelConfig, AppkitBrandConfig, AppkitSeoConfig, AppkitI18nConfig, AppkitImagePattern, FirestoreIndex, FirestoreIndexField, FirestoreFieldOverride, } from "./schema";
@@ -39,15 +39,99 @@ export interface AppkitAuthFixture {
39
39
  /** Cookie header value, e.g. "session=FIXTURE_TOKEN" */
40
40
  cookie: string;
41
41
  }
42
+ export interface FirestoreIndexField {
43
+ fieldPath: string;
44
+ order?: "ASCENDING" | "DESCENDING";
45
+ arrayConfig?: "CONTAINS";
46
+ }
47
+ export interface FirestoreIndex {
48
+ collectionGroup: string;
49
+ queryScope: "COLLECTION" | "COLLECTION_GROUP";
50
+ fields: FirestoreIndexField[];
51
+ }
52
+ export interface FirestoreFieldOverride {
53
+ collectionGroup: string;
54
+ fieldPath: string;
55
+ indexes: Array<{
56
+ order?: "ASCENDING" | "DESCENDING";
57
+ arrayConfig?: "CONTAINS";
58
+ queryScope: "COLLECTION" | "COLLECTION_GROUP";
59
+ }>;
60
+ }
61
+ /** Consumer-specific Firebase extensions merged on top of the appkit base by firebase-merge.mjs. */
62
+ export interface AppkitFirebaseExtensions {
63
+ /** Additional Firestore composite indexes merged on top of the appkit base (deduplicated). */
64
+ indexes?: FirestoreIndex[];
65
+ /** Additional Firestore field overrides appended to the base list. */
66
+ fieldOverrides?: FirestoreFieldOverride[];
67
+ /** Additional RTDB rule paths deep-merged into the base rules object. */
68
+ database?: Record<string, unknown>;
69
+ /** Raw Firestore security rules block injected inside the top-level match block. */
70
+ firestoreRules?: string;
71
+ /** Raw Storage security rules block injected inside the bucket match block. */
72
+ storageRules?: string;
73
+ }
42
74
  export interface AppkitFirebaseConfig {
43
75
  /** Firebase project ID */
44
76
  projectId: string;
45
- /** Path to firestore.indexes.json base file */
77
+ /** Path to firestore.indexes.json base file (default: appkit/firebase/base/firestore.indexes.json) */
46
78
  indexesPath?: string;
47
79
  /** Collections to include in reset (undefined = all) */
48
80
  resetCollections?: string[];
49
81
  /** Firebase Functions region */
50
82
  functionsRegion?: string;
83
+ /** Consumer-specific extensions merged on top of the appkit base by `npm run firebase:generate`. */
84
+ extensions?: AppkitFirebaseExtensions;
85
+ }
86
+ export interface AppkitBrandConfig {
87
+ /** Display name of the brand, e.g. "LetItRip" */
88
+ name: string;
89
+ /** Short / abbreviated name, e.g. "LT" */
90
+ shortName?: string;
91
+ /** One-line brand description used in meta tags and footers */
92
+ description?: string;
93
+ /** Tagline shown in footer / about sections */
94
+ madeInText?: string;
95
+ socialUrls?: {
96
+ instagram?: string;
97
+ twitter?: string;
98
+ whatsapp?: string;
99
+ youtube?: string;
100
+ facebook?: string;
101
+ linkedin?: string;
102
+ };
103
+ }
104
+ export interface AppkitSeoConfig {
105
+ /** Canonical site URL, e.g. "https://letitrip.in" */
106
+ siteUrl: string;
107
+ /** Default page <title> */
108
+ defaultTitle?: string;
109
+ /** Default meta description */
110
+ defaultDescription?: string;
111
+ /** Default OG image path (relative to public/) */
112
+ defaultImage?: string;
113
+ /** OG site name */
114
+ siteName?: string;
115
+ /** Twitter/X handle including @, e.g. "@letitrip" */
116
+ twitterHandle?: string;
117
+ /** BCP 47 locale tag for OG/schema.org, e.g. "en-IN" */
118
+ locale?: string;
119
+ }
120
+ export interface AppkitI18nConfig {
121
+ /** next-intl localePrefix strategy (default: "never") */
122
+ localePrefix?: "never" | "always" | "as-needed";
123
+ /**
124
+ * Set false to suppress the Set-Cookie: Next-Locale header, which forces
125
+ * cache-control: private and breaks Vercel ISR. Disable when running a single
126
+ * locale (default: false).
127
+ */
128
+ enableLocaleCookie?: boolean;
129
+ }
130
+ export interface AppkitImagePattern {
131
+ protocol?: "http" | "https";
132
+ hostname: string;
133
+ port?: string;
134
+ pathname?: string;
51
135
  }
52
136
  export interface AppkitVercelConfig {
53
137
  /** Vercel project ID */
@@ -56,12 +140,22 @@ export interface AppkitVercelConfig {
56
140
  team?: string;
57
141
  /** Path to .env file to sync */
58
142
  envFile?: string;
143
+ /** Deployment regions, e.g. ["bom1"] */
144
+ regions?: string[];
59
145
  }
60
146
  export interface AppkitConfig {
61
147
  /** Base URL for smoke tests and theme probes (default: http://localhost:3000) */
62
148
  baseUrl?: string;
63
149
  /** Supported locales (default: ["en"]) */
64
150
  locales?: string[];
151
+ /** Brand identity — name, social URLs, taglines */
152
+ brand?: AppkitBrandConfig;
153
+ /** SEO defaults — siteUrl, defaultTitle, OG image, Twitter handle */
154
+ seo?: AppkitSeoConfig;
155
+ /** i18n / next-intl routing options */
156
+ i18n?: AppkitI18nConfig;
157
+ /** next/image remotePatterns for external image hosts (demo / seed data) */
158
+ externalImagePatterns?: AppkitImagePattern[];
65
159
  /** Route configuration for smoke tests and theme probing */
66
160
  routes?: {
67
161
  /** Routes to SSR-verify: must return 200 + expected strings in initial HTML */
@@ -18,6 +18,6 @@ export type { AppkitLabelSet } from "./_internal/client/i18n/LabelsProvider";
18
18
  export { AppShell, DashboardScaffold } from "./_internal/client/scaffolds/index";
19
19
  export type { AppShellProps, AppShellRenderContext, DashboardScaffoldProps, DashboardScaffoldRenderContext, } from "./_internal/client/scaffolds/index";
20
20
  export { toClient, clientInitial } from "./_internal/shared/serialization/index";
21
- export type { AppkitConfig } from "./_internal/shared/config/schema";
21
+ export type { AppkitConfig, AppkitFirebaseConfig, AppkitFirebaseExtensions, AppkitVercelConfig, AppkitBrandConfig, AppkitSeoConfig, AppkitI18nConfig, AppkitImagePattern, FirestoreIndex, FirestoreIndexField, FirestoreFieldOverride, } from "./_internal/shared/config/schema";
22
22
  export { SEMANTIC_COLORS, SEMANTIC_COLORS_DARK, SEMANTIC_RADIUS, SEMANTIC_SHADOWS, SEMANTIC_Z_INDEX, MOTION_TOKENS, BREAKPOINTS, PLATFORM_LIMITS, } from "./_internal/shared/tokens/index";
23
23
  export type { Responsive, Breakpoint, SemanticColor } from "./_internal/shared/tokens/index";
package/dist/client.d.ts CHANGED
@@ -8,6 +8,9 @@ export { Modal } from "./ui/components/Modal";
8
8
  export { SideDrawer } from "./ui/components/SideDrawer";
9
9
  export { SideModal } from "./ui/components/SideModal";
10
10
  export { UnsavedChangesModal } from "./ui/components/UnsavedChangesModal";
11
+ export { LoginRequiredModal } from "./ui/components/LoginRequiredModal";
12
+ export type { LoginRequiredModalProps } from "./ui/components/LoginRequiredModal";
13
+ export { isAuthError } from "./utils/auth-error";
11
14
  export { useToast } from "./ui/components/Toast";
12
15
  export { useAuth } from "./react/contexts/SessionContext";
13
16
  export { useCamera } from "./react/hooks/useCamera";
package/dist/client.js CHANGED
@@ -27,6 +27,11 @@ export { SideModal } from "./ui/components/SideModal";
27
27
  // [CLIENT-ONLY]-Cannot run in SSR mode â€" uses browser-only APIs (window, navigator, localStorage, matchMedia, DOM events) that do not exist in Node.js.
28
28
  // UnsavedChangesModal - Component for unsaved changes modal.
29
29
  export { UnsavedChangesModal } from "./ui/components/UnsavedChangesModal";
30
+ // [CLIENT-ONLY]-Cannot run in SSR mode — uses browser-only APIs (window.location).
31
+ // LoginRequiredModal - Modal prompting unauthenticated users to log in.
32
+ export { LoginRequiredModal } from "./ui/components/LoginRequiredModal";
33
+ // isAuthError - Detects auth/authorization errors from server actions or fetch responses.
34
+ export { isAuthError } from "./utils/auth-error";
30
35
  // [CLIENT-ONLY]-Cannot run in SSR mode â€" uses browser-only APIs (window, navigator, localStorage, matchMedia, DOM events) that do not exist in Node.js.
31
36
  // useToast - React hook for use toast.
32
37
  export { useToast } from "./ui/components/Toast";
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useState, useTransition } from "react";
4
- import { Alert, Badge, Button, Div, Heading, Spinner, Text } from "../../../ui";
4
+ import { Alert, Badge, Button, Div, Heading, LoginRequiredModal, Spinner, Text } from "../../../ui";
5
+ import { isAuthError } from "../../../utils/auth-error";
5
6
  function formatRupees(amount) {
6
7
  if (amount === undefined || amount === null)
7
8
  return "—";
@@ -32,7 +33,7 @@ function statusVariant(status) {
32
33
  default: return "default";
33
34
  }
34
35
  }
35
- function BuyerOfferCard({ offer, onAcceptCounter, onWithdraw, onCheckout, onUpdate }) {
36
+ function BuyerOfferCard({ offer, onAcceptCounter, onWithdraw, onCheckout, onUpdate, onNeedsLogin }) {
36
37
  const [error, setError] = useState("");
37
38
  const [isPending, startTransition] = useTransition();
38
39
  const [confirming, setConfirming] = useState(null);
@@ -45,7 +46,12 @@ function BuyerOfferCard({ offer, onAcceptCounter, onWithdraw, onCheckout, onUpda
45
46
  setConfirming(null);
46
47
  }
47
48
  catch (err) {
48
- setError(err instanceof Error ? err.message : "Something went wrong.");
49
+ if (isAuthError(err)) {
50
+ onNeedsLogin();
51
+ }
52
+ else {
53
+ setError(err instanceof Error ? err.message : "Something went wrong.");
54
+ }
49
55
  }
50
56
  });
51
57
  }
@@ -55,13 +61,19 @@ export function UserOffersPanel({ fetchEndpoint = "/api/user/offers", onAcceptCo
55
61
  const [offers, setOffers] = useState([]);
56
62
  const [loading, setLoading] = useState(true);
57
63
  const [fetchError, setFetchError] = useState("");
64
+ const [showLoginModal, setShowLoginModal] = useState(false);
58
65
  const loadOffers = useCallback(async () => {
59
66
  setLoading(true);
60
67
  setFetchError("");
61
68
  try {
62
69
  const res = await fetch(fetchEndpoint);
63
- if (!res.ok)
70
+ if (!res.ok) {
71
+ if (res.status === 401 || res.status === 403) {
72
+ setShowLoginModal(true);
73
+ return;
74
+ }
64
75
  throw new Error(`Error ${res.status}`);
76
+ }
65
77
  const json = (await res.json());
66
78
  const items = Array.isArray(json) ? json : (json.items ?? []);
67
79
  setOffers(items);
@@ -77,5 +89,5 @@ export function UserOffersPanel({ fetchEndpoint = "/api/user/offers", onAcceptCo
77
89
  function handleUpdate(id, patch) {
78
90
  setOffers((prev) => prev.map((o) => (o.id === id ? { ...o, ...patch } : o)));
79
91
  }
80
- return (_jsxs(Div, { className: `space-y-4 ${className}`, children: [_jsxs(Div, { className: "flex items-center justify-between", children: [_jsx(Heading, { level: 2, className: "text-lg font-semibold text-zinc-900 dark:text-zinc-100", children: "My Offers" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: loadOffers, disabled: loading, className: "border border-zinc-300 dark:border-zinc-600 text-xs", children: loading ? "Refreshing…" : "Refresh" })] }), fetchError && _jsx(Alert, { variant: "error", children: _jsx(Text, { className: "text-sm", children: fetchError }) }), loading && (_jsx(Div, { className: "flex justify-center py-12", children: _jsx(Spinner, { size: "lg" }) })), !loading && offers.length === 0 && (_jsx(Div, { className: "text-center py-12", children: _jsx(Text, { className: "text-zinc-400 dark:text-zinc-500 text-sm", children: "No offers yet" }) })), !loading && offers.length > 0 && (_jsx(Div, { className: "space-y-3", children: offers.map((offer) => (_jsx(BuyerOfferCard, { offer: offer, onAcceptCounter: onAcceptCounter, onWithdraw: onWithdraw, onCheckout: onCheckout, onUpdate: handleUpdate }, offer.id))) }))] }));
92
+ return (_jsxs(Div, { className: `space-y-4 ${className}`, children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to manage your offers. Please log in or create an account to continue." }), _jsxs(Div, { className: "flex items-center justify-between", children: [_jsx(Heading, { level: 2, className: "text-lg font-semibold text-zinc-900 dark:text-zinc-100", children: "My Offers" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: loadOffers, disabled: loading, className: "border border-zinc-300 dark:border-zinc-600 text-xs", children: loading ? "Refreshing…" : "Refresh" })] }), fetchError && _jsx(Alert, { variant: "error", children: _jsx(Text, { className: "text-sm", children: fetchError }) }), loading && (_jsx(Div, { className: "flex justify-center py-12", children: _jsx(Spinner, { size: "lg" }) })), !loading && offers.length === 0 && (_jsx(Div, { className: "text-center py-12", children: _jsx(Text, { className: "text-zinc-400 dark:text-zinc-500 text-sm", children: "No offers yet" }) })), !loading && offers.length > 0 && (_jsx(Div, { className: "space-y-3", children: offers.map((offer) => (_jsx(BuyerOfferCard, { offer: offer, onAcceptCounter: onAcceptCounter, onWithdraw: onWithdraw, onCheckout: onCheckout, onUpdate: handleUpdate, onNeedsLogin: () => setShowLoginModal(true) }, offer.id))) }))] }));
81
93
  }
@@ -137,8 +137,24 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, pr
137
137
  }));
138
138
  return _jsx(CollapsibleBidHistory, { bids: bids, currency: currency });
139
139
  }, renderRelated: () => {
140
+ const now = new Date();
140
141
  const related = relatedDocs
141
- .filter((r) => r.id !== product.id && r.listingType === "auction")
142
+ .filter((r) => {
143
+ if (r.id === product.id || r.listingType !== "auction")
144
+ return false;
145
+ const s = r.status;
146
+ if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
147
+ return false;
148
+ if (r.isSold === true)
149
+ return false;
150
+ const end = r.auctionEndDate;
151
+ if (!end)
152
+ return true;
153
+ const endDate = typeof end.toDate === "function"
154
+ ? end.toDate()
155
+ : end instanceof Date ? end : new Date(String(end));
156
+ return endDate > now;
157
+ })
142
158
  .slice(0, 4)
143
159
  .map((r) => ({
144
160
  id: String(r.id ?? ""),
@@ -2,13 +2,15 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useTransition } from "react";
4
4
  import { formatCurrency } from "../../../utils/number.formatter";
5
- import { Button, Div, Input, Row, Span, Stack, Text } from "../../../ui";
5
+ import { isAuthError } from "../../../utils/auth-error";
6
+ import { Button, Div, Input, LoginRequiredModal, Row, Span, Stack, Text } from "../../../ui";
6
7
  export function PlaceBidFormClient({ productId, currentBid, startingBid, minBidIncrement, currency, isEnded, buyNowPrice, bidCount, tags = [], onPlaceBid, }) {
7
8
  const minBid = currentBid + minBidIncrement;
8
9
  const [bidAmount, setBidAmount] = useState(String(minBid));
9
10
  const [isPending, startTransition] = useTransition();
10
11
  const [error, setError] = useState(null);
11
12
  const [success, setSuccess] = useState(false);
13
+ const [showLoginModal, setShowLoginModal] = useState(false);
12
14
  function handleSubmit(e) {
13
15
  e.preventDefault();
14
16
  const amount = Number(bidAmount);
@@ -33,11 +35,14 @@ export function PlaceBidFormClient({ productId, currentBid, startingBid, minBidI
33
35
  setBidAmount(String(amount + minBidIncrement));
34
36
  }
35
37
  catch (err) {
36
- setError(err instanceof Error && err.message
37
- ? err.message
38
- : "Failed to place bid. Please try again.");
38
+ if (isAuthError(err)) {
39
+ setShowLoginModal(true);
40
+ }
41
+ else {
42
+ setError(err instanceof Error ? err.message : "Failed to place bid. Please try again.");
43
+ }
39
44
  }
40
45
  });
41
46
  }
42
- 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("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))) }) }))] }));
47
+ 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))) }) }))] }));
43
48
  }
@@ -1,5 +1,5 @@
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
  /**
4
4
  * BundleBuyNowCta — direct-checkout CTA for bundle detail pages.
5
5
  *
@@ -8,13 +8,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * No qty input — bundles are always qty=1 per checkout.
9
9
  */
10
10
  import { useState, useCallback } from "react";
11
- import { Button, Stack, Text } from "../../../ui";
11
+ import { Button, LoginRequiredModal, Stack, Text } from "../../../ui";
12
12
  import { useToast } from "../../../ui";
13
+ import { isAuthError } from "../../../utils/auth-error";
13
14
  import { BUNDLE_COPY } from "../../../_internal/shared/features/categories/bundle-copy";
14
15
  export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, }) {
15
16
  const { showToast } = useToast();
16
17
  const [submitting, setSubmitting] = useState(false);
17
18
  const [error, setError] = useState(null);
19
+ const [showLoginModal, setShowLoginModal] = useState(false);
18
20
  const handleClick = useCallback(async () => {
19
21
  setError(null);
20
22
  setSubmitting(true);
@@ -22,9 +24,14 @@ export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, }) {
22
24
  await onBuyNow({ bundleSlug });
23
25
  }
24
26
  catch (err) {
25
- const message = err instanceof Error ? err.message : BUNDLE_COPY.detail.ctaErrorFallback;
26
- setError(message);
27
- showToast(message, "error");
27
+ if (isAuthError(err)) {
28
+ setShowLoginModal(true);
29
+ }
30
+ else {
31
+ const message = err instanceof Error ? err.message : BUNDLE_COPY.detail.ctaErrorFallback;
32
+ setError(message);
33
+ showToast(message, "error");
34
+ }
28
35
  }
29
36
  finally {
30
37
  setSubmitting(false);
@@ -33,5 +40,5 @@ export function BundleBuyNowCta({ bundleSlug, outOfStock = false, onBuyNow, }) {
33
40
  if (outOfStock) {
34
41
  return (_jsxs(Stack, { gap: "xs", "aria-live": "polite", children: [_jsx(Button, { variant: "primary", disabled: true, "aria-disabled": true, children: BUNDLE_COPY.detail.ctaOutOfStock }), _jsx(Text, { size: "xs", color: "muted", children: BUNDLE_COPY.detail.ctaHint })] }));
35
42
  }
36
- return (_jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", onClick: handleClick, disabled: submitting, "aria-busy": submitting, children: submitting ? BUNDLE_COPY.detail.ctaAdding : BUNDLE_COPY.detail.ctaBuyNow }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error }))] }));
43
+ return (_jsxs(_Fragment, { children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to purchase this bundle. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", onClick: handleClick, disabled: submitting, "aria-busy": submitting, children: submitting ? BUNDLE_COPY.detail.ctaAdding : BUNDLE_COPY.detail.ctaBuyNow }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error }))] })] }));
37
44
  }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { useAuth } from "../../../react/contexts/SessionContext";
5
5
  import { ROUTES } from "../../../next";
6
+ import { LoginRequiredModal } from "../../../ui";
6
7
  export function EventPollWidget({ eventId, pollConfig, eventStatus, totalEntries, entriesEndpoint, className = "", }) {
7
8
  const { user } = useAuth();
8
9
  const endpoint = entriesEndpoint ?? `/api/events/${eventId}/entries`;
@@ -13,6 +14,7 @@ export function EventPollWidget({ eventId, pollConfig, eventStatus, totalEntries
13
14
  const [isLoading, setIsLoading] = useState(false);
14
15
  const [isSubmitted, setIsSubmitted] = useState(false);
15
16
  const [error, setError] = useState(null);
17
+ const [showLoginModal, setShowLoginModal] = useState(false);
16
18
  const toggleVote = (id) => {
17
19
  if (isMulti) {
18
20
  setSelectedVotes((prev) => prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]);
@@ -37,6 +39,10 @@ export function EventPollWidget({ eventId, pollConfig, eventStatus, totalEntries
37
39
  credentials: "include",
38
40
  });
39
41
  if (!res.ok) {
42
+ if (res.status === 401 || res.status === 403) {
43
+ setShowLoginModal(true);
44
+ return;
45
+ }
40
46
  const data = (await res.json().catch(() => ({})));
41
47
  throw new Error(data.error ?? "Failed to submit vote");
42
48
  }
@@ -58,5 +64,5 @@ export function EventPollWidget({ eventId, pollConfig, eventStatus, totalEntries
58
64
  if (isSubmitted) {
59
65
  return (_jsxs("div", { className: `rounded-xl border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 px-6 py-8 text-center space-y-2 ${className}`, "data-section": "eventpollwidget-div-5", children: [_jsx("p", { className: "font-semibold text-green-700 dark:text-green-300", children: "Vote recorded!" }), _jsx("p", { className: "text-sm text-zinc-500 dark:text-zinc-400", children: "Thanks for participating." })] }));
60
66
  }
61
- return (_jsxs("div", { className: `space-y-4 ${className}`, "data-section": "eventpollwidget-div-6", children: [_jsx("p", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-200", children: isMulti ? "Select all that apply:" : "Choose one:" }), _jsx("div", { className: "space-y-2", "data-section": "eventpollwidget-div-7", children: pollConfig.options.map((opt) => (_jsxs("label", { className: "flex items-center gap-3 cursor-pointer rounded-lg border border-zinc-200 dark:border-zinc-700 px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors", children: [_jsx("input", { type: isMulti ? "checkbox" : "radio", name: `poll-${eventId}`, value: opt.id, checked: selectedVotes.includes(opt.id), onChange: () => toggleVote(opt.id), className: "accent-primary" }), _jsx("span", { className: "text-sm text-zinc-700 dark:text-zinc-300", children: opt.label })] }, opt.id))) }), pollConfig.allowComment && (_jsx("textarea", { value: comment, onChange: (e) => setComment(e.target.value), placeholder: "Add a comment (optional)", rows: 3, className: "w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary" })), error && _jsx("p", { className: "text-sm text-red-500", children: error }), _jsx("button", { type: "button", onClick: handleSubmit, disabled: isLoading || selectedVotes.length === 0, className: "w-full rounded-xl bg-primary px-6 py-3 text-sm font-semibold text-white hover:bg-primary-600 disabled:opacity-60", children: isLoading ? "Submitting…" : "Submit Vote" })] }));
67
+ return (_jsxs("div", { className: `space-y-4 ${className}`, "data-section": "eventpollwidget-div-6", children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to vote in this poll. Please log in or create an account to continue." }), _jsx("p", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-200", children: isMulti ? "Select all that apply:" : "Choose one:" }), _jsx("div", { className: "space-y-2", "data-section": "eventpollwidget-div-7", children: pollConfig.options.map((opt) => (_jsxs("label", { className: "flex items-center gap-3 cursor-pointer rounded-lg border border-zinc-200 dark:border-zinc-700 px-4 py-3 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors", children: [_jsx("input", { type: isMulti ? "checkbox" : "radio", name: `poll-${eventId}`, value: opt.id, checked: selectedVotes.includes(opt.id), onChange: () => toggleVote(opt.id), className: "accent-primary" }), _jsx("span", { className: "text-sm text-zinc-700 dark:text-zinc-300", children: opt.label })] }, opt.id))) }), pollConfig.allowComment && (_jsx("textarea", { value: comment, onChange: (e) => setComment(e.target.value), placeholder: "Add a comment (optional)", rows: 3, className: "w-full rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary" })), error && _jsx("p", { className: "text-sm text-red-500", children: error }), _jsx("button", { type: "button", onClick: handleSubmit, disabled: isLoading || selectedVotes.length === 0, className: "w-full rounded-xl bg-primary px-6 py-3 text-sm font-semibold text-white hover:bg-primary-600 disabled:opacity-60", children: isLoading ? "Submitting…" : "Submit Vote" })] }));
62
68
  }
@@ -1,7 +1,8 @@
1
1
  "use client";
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useMemo, useState } from "react";
4
- import { Button, Div, Heading, Span, Text } from "../../../ui";
4
+ import { Button, Div, Heading, LoginRequiredModal, Span, Text } from "../../../ui";
5
+ import { isAuthError } from "../../../utils/auth-error";
5
6
  const DEFAULT_LABELS = {
6
7
  heading: "Spin the Wheel",
7
8
  spinButton: "Spin",
@@ -20,6 +21,7 @@ export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, in
20
21
  const [resultPrizeId, setResultPrizeId] = useState(initialPrizeId);
21
22
  const [resultCoupon, setResultCoupon] = useState(initialCouponCode);
22
23
  const [error, setError] = useState(null);
24
+ const [showLoginModal, setShowLoginModal] = useState(false);
23
25
  const now = Date.now();
24
26
  const startMs = windowStart ? new Date(windowStart).getTime() : null;
25
27
  const endMs = windowEnd ? new Date(windowEnd).getTime() : null;
@@ -43,15 +45,20 @@ export function SpinWheelView({ eventId, prizes, alreadyUsed, initialPrizeId, in
43
45
  }
44
46
  }, ANIMATION_MS);
45
47
  }
46
- catch {
48
+ catch (err) {
47
49
  setSpinning(false);
48
- setError(l.errorFallback);
50
+ if (isAuthError(err)) {
51
+ setShowLoginModal(true);
52
+ }
53
+ else {
54
+ setError(l.errorFallback);
55
+ }
49
56
  }
50
57
  }, [disabled, eventId, l.errorFallback, onSpin]);
51
58
  const wonPrize = resultPrizeId
52
59
  ? activePrizes.find((p) => p.id === resultPrizeId)
53
60
  : undefined;
54
- return (_jsxs(Div, { className: "space-y-4", children: [_jsxs(Heading, { level: 2, className: "text-xl font-semibold text-zinc-900 dark:text-zinc-100", children: ["\uD83C\uDFA1 ", l.heading] }), _jsxs(Div, { className: "relative mx-auto aspect-square w-64 overflow-hidden rounded-full border-4 border-amber-400 bg-gradient-to-br from-amber-100 via-rose-100 to-violet-100 dark:from-amber-900/40 dark:via-rose-900/40 dark:to-violet-900/40", "aria-label": "Spin wheel", children: [_jsx(Div, { className: "absolute inset-0 flex items-center justify-center", style: {
61
+ return (_jsxs(Div, { className: "space-y-4", children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to spin the wheel. Please log in or create an account to continue." }), _jsxs(Heading, { level: 2, className: "text-xl font-semibold text-zinc-900 dark:text-zinc-100", children: ["\uD83C\uDFA1 ", l.heading] }), _jsxs(Div, { className: "relative mx-auto aspect-square w-64 overflow-hidden rounded-full border-4 border-amber-400 bg-gradient-to-br from-amber-100 via-rose-100 to-violet-100 dark:from-amber-900/40 dark:via-rose-900/40 dark:to-violet-900/40", "aria-label": "Spin wheel", children: [_jsx(Div, { className: "absolute inset-0 flex items-center justify-center", style: {
55
62
  animation: spinning
56
63
  ? `lir-spin ${ANIMATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
57
64
  : undefined,
@@ -1,12 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useTransition } from "react";
4
- import { Button, Div, Stack, Text } from "../../../ui";
4
+ import { Button, Div, LoginRequiredModal, Stack, Text } from "../../../ui";
5
+ import { isAuthError } from "../../../utils/auth-error";
5
6
  import { formatCurrency } from "../../../utils/number.formatter";
6
7
  export function PreOrderActionsClient({ productId, price, currency, depositAmount, depositPercent, isCancellable, tags = [], onReserveNow, }) {
7
8
  const [isPending, startTransition] = useTransition();
8
9
  const [error, setError] = useState(null);
9
10
  const [success, setSuccess] = useState(false);
11
+ const [showLoginModal, setShowLoginModal] = useState(false);
10
12
  function handleReserve() {
11
13
  setError(null);
12
14
  setSuccess(false);
@@ -16,11 +18,16 @@ export function PreOrderActionsClient({ productId, price, currency, depositAmoun
16
18
  setSuccess(true);
17
19
  }
18
20
  catch (err) {
19
- setError(err instanceof Error ? err.message : "Failed to reserve. Please try again.");
21
+ if (isAuthError(err)) {
22
+ setShowLoginModal(true);
23
+ }
24
+ else {
25
+ setError(err instanceof Error ? err.message : "Failed to reserve. Please try again.");
26
+ }
20
27
  }
21
28
  });
22
29
  }
23
- return (_jsxs(Div, { className: "space-y-4", children: [price !== null && (_jsxs(Div, { children: [_jsx(Text, { className: "text-2xl font-bold text-zinc-900 dark:text-zinc-50", children: formatCurrency(price, currency) }), depositAmount !== null && (_jsxs(Text, { className: "mt-0.5 text-xs text-zinc-500", children: ["Reserve with ", formatCurrency(depositAmount, currency), depositPercent !== null ? ` (${depositPercent}% deposit)` : ""] }))] })), 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 Reserved! Redirecting to checkout\u2026" })), _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isPending, onClick: handleReserve, children: isPending ? "Processing…" : "Reserve Now" }), isCancellable && (_jsx(Text, { className: "text-center text-xs text-zinc-500 dark:text-zinc-400", children: "\u2713 Free cancellation before production" }))] }), tags.length > 0 && (_jsx(Div, { className: "border-t border-zinc-200 dark:border-zinc-700 pt-4", children: _jsx("div", { className: "flex flex-wrap gap-1.5", 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))) }) })), _jsx(Div, { className: "border-t border-zinc-200 dark:border-zinc-700 pt-4", children: _jsx("div", { className: "flex flex-wrap gap-4 justify-center text-center", children: [
30
+ return (_jsxs(Div, { className: "space-y-4", children: [price !== null && (_jsxs(Div, { children: [_jsx(Text, { className: "text-2xl font-bold text-zinc-900 dark:text-zinc-50", children: formatCurrency(price, currency) }), depositAmount !== null && (_jsxs(Text, { className: "mt-0.5 text-xs text-zinc-500", children: ["Reserve with ", formatCurrency(depositAmount, currency), depositPercent !== null ? ` (${depositPercent}% deposit)` : ""] }))] })), 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 Reserved! Redirecting to checkout\u2026" })), _jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to reserve this pre-order. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isPending, onClick: handleReserve, children: isPending ? "Processing…" : "Reserve Now" }), isCancellable && (_jsx(Text, { className: "text-center text-xs text-zinc-500 dark:text-zinc-400", children: "\u2713 Free cancellation before production" }))] }), tags.length > 0 && (_jsx(Div, { className: "border-t border-zinc-200 dark:border-zinc-700 pt-4", children: _jsx("div", { className: "flex flex-wrap gap-1.5", 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))) }) })), _jsx(Div, { className: "border-t border-zinc-200 dark:border-zinc-700 pt-4", children: _jsx("div", { className: "flex flex-wrap gap-4 justify-center text-center", children: [
24
31
  { icon: "🔒", label: "Secure\nPayment" },
25
32
  { icon: "📅", label: "Guaranteed\nDelivery" },
26
33
  { icon: "↩", label: "Free\nCancellation" },
@@ -1,11 +1,13 @@
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
- import { Button, Div, Input, Span, Text } from "../../../ui";
4
+ import { Button, Div, Input, LoginRequiredModal, Span, Text } from "../../../ui";
5
+ import { isAuthError } from "../../../utils/auth-error";
5
6
  import { formatCurrency } from "../../../utils/number.formatter";
6
7
  export function MakeOfferButton({ productId, listedPrice, currency, minOfferPercent = 70, onMakeOffer, className = "", }) {
7
8
  const [state, setState] = useState("idle");
8
9
  const [errorMsg, setErrorMsg] = useState("");
10
+ const [showLoginModal, setShowLoginModal] = useState(false);
9
11
  const [isPending, startTransition] = useTransition();
10
12
  const minOffer = Math.round(listedPrice * (minOfferPercent / 100));
11
13
  const defaultOffer = Math.round(listedPrice * 0.9);
@@ -41,13 +43,19 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
41
43
  setState("success");
42
44
  }
43
45
  catch (err) {
44
- const msg = err instanceof Error ? err.message : String(err);
45
- if (msg.includes("active offer") || msg.includes("ACTIVE_OFFER")) {
46
- setState("pending");
46
+ if (isAuthError(err)) {
47
+ setState("idle");
48
+ setShowLoginModal(true);
47
49
  }
48
50
  else {
49
- setErrorMsg(msg || "Could not send offer. Please try again.");
50
- setState("error");
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ if (msg.includes("active offer") || msg.includes("ACTIVE_OFFER")) {
53
+ setState("pending");
54
+ }
55
+ else {
56
+ setErrorMsg(msg || "Could not send offer. Please try again.");
57
+ setState("error");
58
+ }
51
59
  }
52
60
  }
53
61
  });
@@ -64,5 +72,5 @@ export function MakeOfferButton({ productId, listedPrice, currency, minOfferPerc
64
72
  if (state === "error") {
65
73
  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" })] })] }));
66
74
  }
67
- return (_jsx(Button, { variant: "ghost", size: "md", className: `w-full border border-zinc-300 dark:border-zinc-600 ${className}`, onClick: handleOpenConfirm, children: "Make Offer" }));
75
+ 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" })] }));
68
76
  }
@@ -15,7 +15,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  * nor LetItRip admins can influence the outcome.
16
16
  */
17
17
  import { useCallback, useEffect, useRef, useState } from "react";
18
- import { Button, Div, Heading, Modal, Stack, Text } from "../../../ui";
18
+ import { Button, Div, Heading, LoginRequiredModal, Modal, Stack, Text } from "../../../ui";
19
+ import { isAuthError } from "../../../utils/auth-error";
19
20
  import { PrizeDrawCollage } from "./PrizeDrawCollage";
20
21
  const REVEAL_DURATION_MS = 3200; // total animation length
21
22
  const CYCLE_INTERVAL_FAST = 80;
@@ -26,6 +27,7 @@ export function PrizeRevealModal({ open, onClose, items, orderId, productId, onR
26
27
  const [winner, setWinner] = useState(initialPrizeWon);
27
28
  const [errorMessage, setErrorMessage] = useState("");
28
29
  const [effectiveRngUrl, setEffectiveRngUrl] = useState(rngSourceUrl);
30
+ const [showLoginModal, setShowLoginModal] = useState(false);
29
31
  const cycleTimerRef = useRef(null);
30
32
  const endTimerRef = useRef(null);
31
33
  // Clean up timers on close/unmount so we don't leak between sessions.
@@ -83,12 +85,23 @@ export function PrizeRevealModal({ open, onClose, items, orderId, productId, onR
83
85
  body: JSON.stringify({ orderId }),
84
86
  });
85
87
  const json = (await res.json());
86
- if (!res.ok)
88
+ if (!res.ok) {
89
+ if (res.status === 401 || res.status === 403) {
90
+ setPhase("idle");
91
+ setShowLoginModal(true);
92
+ return;
93
+ }
87
94
  throw new Error("Reveal request failed");
95
+ }
88
96
  response = json.data ?? {};
89
97
  }
90
98
  }
91
99
  catch (err) {
100
+ if (isAuthError(err)) {
101
+ setPhase("idle");
102
+ setShowLoginModal(true);
103
+ return;
104
+ }
92
105
  setPhase("error");
93
106
  setErrorMessage(err instanceof Error ? err.message : "Reveal request failed");
94
107
  return;
@@ -117,8 +130,8 @@ export function PrizeRevealModal({ open, onClose, items, orderId, productId, onR
117
130
  }, REVEAL_DURATION_MS);
118
131
  }, [onReveal, orderId, phase, productId, startAnimation]);
119
132
  const winnerImg = winner?.images?.[0];
120
- return (_jsx(Modal, { isOpen: open, onClose: onClose, title: "Prize Reveal", size: "lg", children: _jsxs(Stack, { gap: "md", children: [phase === "refunded" ? (_jsxs(Div, { className: "rounded border border-yellow-400/40 bg-yellow-50 px-4 py-3 text-yellow-900 dark:bg-yellow-900/30 dark:text-yellow-100", children: [_jsx(Heading, { level: 3, className: "mb-1", children: "Pool exhausted \u2014 you've been refunded" }), _jsx(Text, { className: "text-sm", children: "Every prize in this draw was already claimed by the time your entry rolled. Your order has been marked refunded automatically." })] })) : null, phase === "error" ? (_jsxs(Div, { className: "rounded border border-red-400/40 bg-red-50 px-4 py-3 text-red-900 dark:bg-red-900/30 dark:text-red-100", children: [_jsx(Text, { className: "text-sm font-semibold", children: "Something went wrong" }), _jsx(Text, { className: "text-sm", children: errorMessage })] })) : null, _jsx(PrizeDrawCollage, { items: items, hideWonState: true, highlightItemNumber: highlight }), phase === "idle" ? (_jsx(Button, { type: "button", variant: "primary", size: "lg", onClick: handleRevealClick, children: "\u2728 Reveal my prize" })) : null, phase === "revealing" ? (_jsxs(Div, { className: "rounded bg-[var(--appkit-color-surface-muted)] p-4 text-center", children: [_jsx(Text, { className: "text-lg font-semibold", children: "Rolling\u2026" }), _jsx(Text, { className: "text-sm text-[var(--appkit-color-text-muted)]", children: "The winner was locked by the server before this animation started. Hang tight \u2014 we're just making it look pretty." })] })) : null, phase === "won" && winner ? (_jsxs(Div, { className: "rounded-lg border-2 border-[var(--appkit-color-primary)] bg-[var(--appkit-color-surface)] p-4 text-center", children: [_jsx(Text, { className: "text-xs uppercase tracking-wider text-[var(--appkit-color-text-muted)]", children: "You won" }), _jsxs(Heading, { level: 2, className: "my-2", children: ["#", winner.itemNumber, " \u2014 ", winner.title] }), winnerImg ? (
121
- /* eslint-disable-next-line @next/next/no-img-element */
122
- _jsx("img", { src: winnerImg, alt: winner.title, className: "mx-auto max-h-64 rounded" })) : null, winner.estimatedValue != null ? (_jsxs(Text, { className: "mt-2 text-sm text-[var(--appkit-color-text-muted)]", children: ["Estimated value: \u20B9", (winner.estimatedValue / 100).toLocaleString("en-IN")] })) : null] })) : null, _jsxs(Div, { className: "rounded border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-muted)] px-3 py-2 text-xs text-[var(--appkit-color-text-muted)]", children: [_jsx("strong", { className: "text-[var(--appkit-color-text)]", children: "Fairness guarantee:" }), " ", "Winners are picked by ", _jsx("code", { children: "crypto.randomInt" }), " running on LetItRip's server before the animation starts. The animation is theatrical \u2014 neither the store nor LetItRip staff can influence the outcome.", " ", effectiveRngUrl ? (_jsx("a", { href: effectiveRngUrl, target: "_blank", rel: "noreferrer noopener", className: "underline", children: "View RNG source code \u2192" })) : null] })] }) }));
133
+ return (_jsxs(Modal, { isOpen: open, onClose: onClose, title: "Prize Reveal", size: "lg", children: [_jsx(LoginRequiredModal, { isOpen: showLoginModal, onClose: () => setShowLoginModal(false), message: "You need to be signed in to reveal your prize. Please log in or create an account to continue." }), _jsxs(Stack, { gap: "md", children: [phase === "refunded" ? (_jsxs(Div, { className: "rounded border border-yellow-400/40 bg-yellow-50 px-4 py-3 text-yellow-900 dark:bg-yellow-900/30 dark:text-yellow-100", children: [_jsx(Heading, { level: 3, className: "mb-1", children: "Pool exhausted \u2014 you've been refunded" }), _jsx(Text, { className: "text-sm", children: "Every prize in this draw was already claimed by the time your entry rolled. Your order has been marked refunded automatically." })] })) : null, phase === "error" ? (_jsxs(Div, { className: "rounded border border-red-400/40 bg-red-50 px-4 py-3 text-red-900 dark:bg-red-900/30 dark:text-red-100", children: [_jsx(Text, { className: "text-sm font-semibold", children: "Something went wrong" }), _jsx(Text, { className: "text-sm", children: errorMessage })] })) : null, _jsx(PrizeDrawCollage, { items: items, hideWonState: true, highlightItemNumber: highlight }), phase === "idle" ? (_jsx(Button, { type: "button", variant: "primary", size: "lg", onClick: handleRevealClick, children: "\u2728 Reveal my prize" })) : null, phase === "revealing" ? (_jsxs(Div, { className: "rounded bg-[var(--appkit-color-surface-muted)] p-4 text-center", children: [_jsx(Text, { className: "text-lg font-semibold", children: "Rolling\u2026" }), _jsx(Text, { className: "text-sm text-[var(--appkit-color-text-muted)]", children: "The winner was locked by the server before this animation started. Hang tight \u2014 we're just making it look pretty." })] })) : null, phase === "won" && winner ? (_jsxs(Div, { className: "rounded-lg border-2 border-[var(--appkit-color-primary)] bg-[var(--appkit-color-surface)] p-4 text-center", children: [_jsx(Text, { className: "text-xs uppercase tracking-wider text-[var(--appkit-color-text-muted)]", children: "You won" }), _jsxs(Heading, { level: 2, className: "my-2", children: ["#", winner.itemNumber, " \u2014 ", winner.title] }), winnerImg ? (
134
+ /* eslint-disable-next-line @next/next/no-img-element */
135
+ _jsx("img", { src: winnerImg, alt: winner.title, className: "mx-auto max-h-64 rounded" })) : null, winner.estimatedValue != null ? (_jsxs(Text, { className: "mt-2 text-sm text-[var(--appkit-color-text-muted)]", children: ["Estimated value: \u20B9", (winner.estimatedValue / 100).toLocaleString("en-IN")] })) : null] })) : null, _jsxs(Div, { className: "rounded border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-muted)] px-3 py-2 text-xs text-[var(--appkit-color-text-muted)]", children: [_jsx("strong", { className: "text-[var(--appkit-color-text)]", children: "Fairness guarantee:" }), " ", "Winners are picked by ", _jsx("code", { children: "crypto.randomInt" }), " running on LetItRip's server before the animation starts. The animation is theatrical \u2014 neither the store nor LetItRip staff can influence the outcome.", " ", effectiveRngUrl ? (_jsx("a", { href: effectiveRngUrl, target: "_blank", rel: "noreferrer noopener", className: "underline", children: "View RNG source code \u2192" })) : null] })] })] }));
123
136
  }
124
137
  export default PrizeRevealModal;
@@ -182,8 +182,30 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
182
182
  : Promise.resolve([]),
183
183
  ]);
184
184
  const reviews = reviewDocs.map(toReview);
185
+ const _now = new Date();
185
186
  const relatedItems = relatedDocs
186
- .filter((r) => r.id !== product.id)
187
+ .filter((r) => {
188
+ if (r.id === product.id)
189
+ return false;
190
+ const s = r.status;
191
+ if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
192
+ return false;
193
+ if (r.isSold === true)
194
+ return false;
195
+ if (r.availableQuantity === 0)
196
+ return false;
197
+ if (r.listingType === "auction" && r.auctionEndDate) {
198
+ const end = r.auctionEndDate;
199
+ const endDate = typeof end.toDate === "function"
200
+ ? end.toDate()
201
+ : end instanceof Date ? end : new Date(String(end));
202
+ if (endDate <= _now)
203
+ return false;
204
+ }
205
+ if (r.listingType === "prize-draw" && r.prizeRevealStatus === "closed")
206
+ return false;
207
+ return true;
208
+ })
187
209
  .slice(0, 8)
188
210
  .map(toProductItem);
189
211
  const formattedPrice = price !== null ? formatCurrency(price, currency) : null;