diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index 8557ad4..871068f 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -279,12 +279,19 @@ export default function MarketDetailPage({ - {claiming ? ( - - ) : ( - + + {claiming && ( +
+ +
)} {claimError && (

{claimError}

diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index cf5ade0..9f62614 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,13 +1,15 @@ import React from "react"; +import { FiLoader } from "react-icons/fi"; type Variant = "primary" | "secondary" | "ghost"; interface ButtonProps extends React.ButtonHTMLAttributes { variant?: Variant; fullWidth?: boolean; + loading?: boolean; } -export default function Button({ variant = "primary", fullWidth = false, className, children, ...rest }: ButtonProps) { +export default function Button({ variant = "primary", fullWidth = false, loading = false, className, children, ...rest }: ButtonProps) { const base = "inline-flex items-center justify-center rounded-2xl font-semibold transition-transform duration-150"; const variants: Record = { primary: "btn-primary", @@ -15,10 +17,11 @@ export default function Button({ variant = "primary", fullWidth = false, classNa ghost: "bg-transparent text-slate-200 hover:text-white", }; - const classes = `${base} ${variants[variant]} ${fullWidth ? "w-full" : ""} ${className ?? ""}`.trim(); + const classes = `${base} ${variants[variant]} ${fullWidth ? "w-full" : ""} ${loading ? "opacity-70 cursor-wait" : ""} ${className ?? ""}`.trim(); return ( - ); diff --git a/frontend/src/hooks/useToast.tsx b/frontend/src/hooks/useToast.tsx index 8a2328f..f930bb4 100644 --- a/frontend/src/hooks/useToast.tsx +++ b/frontend/src/hooks/useToast.tsx @@ -43,10 +43,11 @@ export function ToastProvider({ children }: { children: ReactNode }) { return ( {children} - {/* Narrow viewports: bottom safe-area, full usable width. sm+: top-right stack. */} + {/* Toast container: bottom on mobile (safe-area aware), top-right on desktop. + Uses max-w-[calc(100vw-1rem)] so toasts never overflow on narrow viewports. */}
{toasts.map((t) => ( diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 3f28ea7..ad3a424 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -56,15 +56,23 @@ export function timeUntil(timestamp: number): string { return `${diff}s`; } +// ── Timestamp Normalisation ───────────────────────────────────────────────── + /** - * Normalize a Unix timestamp that may be seconds or milliseconds to ms. - * Values below ~1e12 are almost certainly seconds; above are ms. + * Normalize a positive Unix timestamp supplied in seconds or milliseconds to ms. + * Returns NaN for invalid / out-of-range values so callers can display a fallback. */ export function toTimestampMs(timestamp: number): number { - if (!Number.isFinite(timestamp)) return Date.now(); - return timestamp < 1e12 ? timestamp * 1000 : timestamp; + if (!Number.isFinite(timestamp) || timestamp <= 0) return Number.NaN; + const timestampMs = + timestamp < MILLISECOND_TIMESTAMP_THRESHOLD + ? timestamp * 1_000 + : timestamp; + return timestampMs <= MAX_DATE_TIMESTAMP_MS ? timestampMs : Number.NaN; } +// ── Date / Time Formatting (locale-aware, viewer timezone) ────────────────── + const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", @@ -81,133 +89,65 @@ const TIME_OPTIONS: Intl.DateTimeFormatOptions = { }; /** - * Format a Unix timestamp (seconds) to a locale-aware date/time string. + * Format a Unix timestamp (seconds or ms) to a locale-aware date/time string. * Uses the viewer's browser locale and local timezone automatically. * * Example (en-GB): "12 Jul 2026, 14:30 GMT+1" * Example (en-US): "Jul 12, 2026, 10:30 AM EDT" */ -/** Normalize a positive Unix timestamp supplied in seconds or milliseconds. */ -export function toTimestampMs(timestamp: number): number { - if (!Number.isFinite(timestamp) || timestamp <= 0) return Number.NaN; - const timestampMs = - timestamp < MILLISECOND_TIMESTAMP_THRESHOLD - ? timestamp * 1_000 - : timestamp; - return timestampMs <= MAX_DATE_TIMESTAMP_MS ? timestampMs : Number.NaN; -} - -/** Format a timestamp in the viewer's timezone, including its timezone label. */ export function formatDate( timestamp: number, locale?: Intl.LocalesArgument, options: Intl.DateTimeFormatOptions = {} ): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - // Guard against accidental millisecond values (> year 2100 in seconds ≈ 4_102_444_800) - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; + const timestampMs = toTimestampMs(timestamp); + if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; + return new Intl.DateTimeFormat(locale, { ...DATE_TIME_OPTIONS, ...options, - }).format(new Date(ms)); + }).format(new Date(timestampMs)); } /** - * Format a Unix timestamp to a locale-aware time-only string. + * Format a Unix timestamp (seconds or ms) to a locale-aware time-only string. */ export function formatTime( timestamp: number, locale?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions + options: Intl.DateTimeFormatOptions = {} ): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; + const timestampMs = toTimestampMs(timestamp); + if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; + return new Intl.DateTimeFormat(locale, { ...TIME_OPTIONS, ...options, - }).format(new Date(ms)); + }).format(new Date(timestampMs)); } /** * Format an event timestamp (milliseconds) to a locale-aware date+time string. - * Use this for MarketEvent.timestamp — it is already in milliseconds. + * Use this for MarketEvent.timestamp — it is already in milliseconds, do NOT multiply by 1000. */ export function formatEventTime(timestampMs: number): string { - if (!Number.isFinite(timestampMs) || timestampMs <= 0) return "—"; - return new Date(timestampMs).toLocaleString(undefined, DATE_TIME_OPTIONS); -} - -/** - * Return a human-readable relative time string from a Unix timestamp (seconds). - * Uses the viewer's locale via Intl.RelativeTimeFormat. - * - * Examples: "2 hours ago", "3 days ago", "just now" - */ -export function timeAgo(timestamp: number): string { - if (!Number.isFinite(timestamp) || timestamp <= 0) return "—"; - const ms = timestamp > 4_102_444_800 ? timestamp : timestamp * 1000; - const diffSeconds = Math.floor((Date.now() - ms) / 1000); - - if (diffSeconds < 5) return "just now"; - - const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - - const thresholds: [number, Intl.RelativeTimeFormatUnit][] = [ - [60, "second"], - [3_600, "minute"], - [86_400, "hour"], - [604_800, "day"], - [2_592_000, "week"], - [31_536_000, "month"], - ]; - - for (const [limit, unit] of thresholds) { - if (diffSeconds < limit) { - const idx = thresholds.findIndex(([l]) => l === limit); - const prev = idx > 0 ? thresholds[idx - 1] : [1, "second"] as const; - const divisor = prev[0]; - return rtf.format(-Math.floor(diffSeconds / divisor), unit); - } - } - - return rtf.format(-Math.floor(diffSeconds / 31_536_000), "year"); -} - -/** - * Calculate a winner's payout from a prediction market. - * payout = (userNetBet / winningSideTotal) × totalPool - const timestampMs = toTimestampMs(timestamp); - if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; - - return new Intl.DateTimeFormat(locale, { + if (!Number.isFinite(timestampMs) || timestampMs <= 0) return INVALID_TIMESTAMP; + return new Date(timestampMs).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short", - ...options, - }).format(new Date(timestampMs)); -} - -/** Format only the local time portion of a timestamp, with its timezone label. */ -export function formatTime( - timestamp: number, - locale?: Intl.LocalesArgument, - options: Intl.DateTimeFormatOptions = {} -): string { - const timestampMs = toTimestampMs(timestamp); - if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP; - - return new Intl.DateTimeFormat(locale, { - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - ...options, - }).format(new Date(timestampMs)); + }); } -/** Format a timestamp relative to now while accepting seconds or milliseconds. */ +/** + * Return a human-readable relative time string from a Unix timestamp (seconds or ms). + * Uses the viewer's locale via Intl.RelativeTimeFormat. + * + * Examples: "2 hours ago", "3 days ago", "just now" + */ export function timeAgo( timestamp: number, locale?: Intl.LocalesArgument @@ -239,24 +179,11 @@ export function timeAgo( ); } -/** Format an event timestamp (milliseconds) to a locale-aware date+time string. - * Use this for `MarketEvent.timestamp` – it is already in milliseconds, do NOT multiply by 1000. */ -export function formatEventTime(timestampMs: number): string { - if (!Number.isFinite(timestampMs) || timestampMs <= 0) return INVALID_TIMESTAMP; - return new Date(timestampMs).toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }); -} +// ── Market Calculations ───────────────────────────────────────────────────── -/** Calculate a winner's payout from a prediction market. - * +/** + * Calculate a winner's payout from a prediction market. * payout = (userNetBet / winningSideTotal) × totalPool - * * All values in XLM (not stroops). */ export function calculatePayout( @@ -268,12 +195,7 @@ export function calculatePayout( return (userNetBet / winningSideTotal) * totalPool; } -/** - * Calculate YES/NO odds percentages from net totals. - * Returns { yesPercent, noPercent } — each 0-100. -/** Calculate YES/NO odds percentages from net totals. - * Returns { yesPercent, noPercent } – each 0-100. - */ +/** Calculate YES/NO odds percentages from net totals. Returns { yesPercent, noPercent } – each 0-100. */ export function calculateOdds( totalYes: number, totalNo: number @@ -284,9 +206,8 @@ export function calculateOdds( return { yesPercent, noPercent: 100 - yesPercent }; } -/** - * Build a Stellar Expert explorer URL for transactions, accounts, or contracts. - */ +// ── Display Helpers ───────────────────────────────────────────────────────── + /** Convert basis points to a percentage string. */ export function bpsToPercent(bps: number): string { return `${bps / 100}%`; @@ -298,11 +219,6 @@ export function explorerUrl( id: string, network: "public" | "testnet" = "public" ): string { - const base = - network === "testnet" - ? "https://stellar.expert/explorer/testnet" - : "https://stellar.expert/explorer/public"; - return `${base}/${type}/${id}`; const base = `https://stellar.expert/explorer/${network}`; switch (type) { case "tx":