Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions frontend/src/app/markets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,19 @@ export default function MarketDetailPage({
</div>
</div>

{claiming ? (
<TxProgress step={claimStage === "idle" ? "building" : claimStage} />
) : (
<Button onClick={handleClaim} variant="primary" fullWidth>
Claim Rewards
</Button>
<Button
onClick={handleClaim}
disabled={claiming}
loading={claiming}
variant="primary"
fullWidth
>
{claiming ? "Claiming..." : "Claim Rewards"}
</Button>
{claiming && (
<div className="mt-3">
<TxProgress step={claimStage === "idle" ? "building" : claimStage} />
</div>
)}
{claimError && (
<p className="text-sm text-accent-red mt-2">{claimError}</p>
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import React from "react";
import { FiLoader } from "react-icons/fi";

type Variant = "primary" | "secondary" | "ghost";

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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<Variant, string> = {
primary: "btn-primary",
secondary: "btn-secondary",
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 (
<button {...rest} className={classes}>
<button {...rest} disabled={rest.disabled || loading} className={classes}>
{loading && <FiLoader className="w-4 h-4 mr-2 animate-spin shrink-0" />}
{children}
</button>
);
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/hooks/useToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={{ showToast }}>
{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. */}
<div
className="fixed z-[200] pointer-events-none flex flex-col gap-2
left-3 right-3 bottom-[max(1rem,env(safe-area-inset-bottom))]
left-2 right-2 bottom-[max(0.75rem,env(safe-area-inset-bottom,0.75rem))]
sm:left-auto sm:right-4 sm:bottom-auto sm:top-4 sm:w-auto sm:max-w-sm"
>
{toasts.map((t) => (
Expand Down
162 changes: 39 additions & 123 deletions frontend/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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}%`;
Expand All @@ -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":
Expand Down