diff --git a/frontend/src/__tests__/leaderboard-page.test.tsx b/frontend/src/__tests__/leaderboard-page.test.tsx
index 94244dc..49dff57 100644
--- a/frontend/src/__tests__/leaderboard-page.test.tsx
+++ b/frontend/src/__tests__/leaderboard-page.test.tsx
@@ -4,8 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lastUpdated: 1_800_000_000 as number | null,
- formatDate: vi.fn(() => "localized timestamp"),
- timeAgo: vi.fn(() => "5 minutes ago"),
+ localizedTimestamp: vi.fn(),
}));
vi.mock("@/hooks/useLeaderboard", () => ({
@@ -22,9 +21,11 @@ vi.mock("@/hooks/useWallet", () => ({
useWallet: () => ({ publicKey: null }),
}));
-vi.mock("@/utils/helpers", () => ({
- formatDate: mocks.formatDate,
- timeAgo: mocks.timeAgo,
+vi.mock("@/components/ui/LocalizedTimestamp", () => ({
+ default: (props: { timestamp: number; mode?: string }) => {
+ mocks.localizedTimestamp(props);
+ return props.mode === "relative" ? "5 minutes ago" : "localized timestamp";
+ },
}));
import LeaderboardPage from "@/app/leaderboard/page";
@@ -35,17 +36,31 @@ describe("LeaderboardPage", () => {
mocks.lastUpdated = 1_800_000_000;
});
- it("formats the last successful leaderboard refresh", () => {
+ it("renders relative and absolute localized refresh timestamps", () => {
render();
- expect(screen.getByText("Last updated: localized timestamp")).toBeInTheDocument();
- expect(mocks.formatDate).toHaveBeenCalledWith(1_800_000_000);
+ expect(screen.getByText(/^Updated 5 minutes ago$/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/^Last updated: localized timestamp$/)
+ ).toBeInTheDocument();
+
+ const props = mocks.localizedTimestamp.mock.calls.map(([value]) => value);
+ expect(props).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ timestamp: 1_800_000_000,
+ mode: "relative",
+ }),
+ expect.objectContaining({ timestamp: 1_800_000_000 }),
+ ])
+ );
});
- it("hides the label before any successful refresh", () => {
+ it("hides timestamp labels before any successful refresh", () => {
mocks.lastUpdated = null;
render();
expect(screen.queryByText(/^Last updated:/)).not.toBeInTheDocument();
+ expect(mocks.localizedTimestamp).not.toHaveBeenCalled();
});
});
diff --git a/frontend/src/__tests__/localized-timestamp.test.tsx b/frontend/src/__tests__/localized-timestamp.test.tsx
new file mode 100644
index 0000000..daa9431
--- /dev/null
+++ b/frontend/src/__tests__/localized-timestamp.test.tsx
@@ -0,0 +1,137 @@
+import React from "react";
+import { act } from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import { hydrateRoot, type Root } from "react-dom/client";
+import { renderToString } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp";
+import { formatDate, formatTime } from "@/utils/helpers";
+
+const timestamp = Date.UTC(2026, 6, 12, 14, 30) / 1_000;
+
+describe("LocalizedTimestamp", () => {
+ it("renders deterministic UTC markup during SSR", () => {
+ const expected = formatDate(timestamp, "en-US", { timeZone: "UTC" });
+ const html = renderToString();
+
+ expect(html).toContain(expected);
+ expect(html).toContain('data-time-zone="UTC"');
+ expect(html.toLowerCase()).toContain('datetime="2026-07-12t14:30:00.000z"');
+ });
+
+ it("hydrates UTC markup before switching to the browser locale and timezone", async () => {
+ const serverValue = formatDate(timestamp, "en-US", { timeZone: "UTC" });
+ const browserValue = formatDate(timestamp, "es-ES", {
+ timeZone: "Europe/Madrid",
+ });
+ const originalLanguages = Object.getOwnPropertyDescriptor(
+ navigator,
+ "languages"
+ );
+ const resolvedOptions = new Intl.DateTimeFormat().resolvedOptions();
+ const resolvedOptionsSpy = vi
+ .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
+ .mockReturnValue({
+ ...resolvedOptions,
+ locale: "es-ES",
+ timeZone: "Europe/Madrid",
+ });
+ const container = document.createElement("div");
+ let root: Root | undefined;
+
+ Object.defineProperty(navigator, "languages", {
+ configurable: true,
+ value: ["es-ES"],
+ });
+ container.innerHTML = renderToString(
+
+ );
+ document.body.appendChild(container);
+
+ try {
+ expect(container).toHaveTextContent(serverValue);
+
+ await act(async () => {
+ root = hydrateRoot(
+ container,
+
+ );
+ });
+
+ await waitFor(() => expect(container).toHaveTextContent(browserValue));
+ expect(container.querySelector("time")).toHaveAttribute(
+ "data-time-zone",
+ "Europe/Madrid"
+ );
+ } finally {
+ if (root) {
+ await act(async () => root?.unmount());
+ }
+ container.remove();
+ resolvedOptionsSpy.mockRestore();
+ if (originalLanguages) {
+ Object.defineProperty(navigator, "languages", originalLanguages);
+ } else {
+ Reflect.deleteProperty(navigator, "languages");
+ }
+ }
+ });
+
+ it.each([
+ ["America/New_York", "en-US"],
+ ["Europe/Madrid", "es-ES"],
+ ["Asia/Tokyo", "ja-JP"],
+ ])("formats the same instant in %s", (timeZone, locale) => {
+ const expected = formatDate(timestamp, locale, { timeZone });
+
+ render(
+
+ );
+
+ expect(screen.getByText(expected)).toHaveAttribute(
+ "data-time-zone",
+ timeZone
+ );
+ });
+
+ it("supports a time-only view", () => {
+ const expected = formatTime(timestamp, "en-GB", {
+ timeZone: "Europe/London",
+ });
+
+ render(
+
+ );
+
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ });
+
+ it("renders relative timestamps deterministically when nowMs is supplied", () => {
+ const nowMs = Date.UTC(2026, 6, 12, 16, 30);
+
+ render(
+
+ );
+
+ expect(screen.getByText("2 hours ago")).toBeInTheDocument();
+ });
+
+ it("renders invalid input as an em dash", () => {
+ render();
+ expect(screen.getByText("—")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/app/leaderboard/page.tsx b/frontend/src/app/leaderboard/page.tsx
index 191619b..caa98e9 100644
--- a/frontend/src/app/leaderboard/page.tsx
+++ b/frontend/src/app/leaderboard/page.tsx
@@ -7,7 +7,7 @@ import LeaderboardTabs from "@/components/leaderboard/LeaderboardTabs";
import EmptyState from "@/components/ui/EmptyState";
import ErrorBoundary from "@/components/ui/ErrorBoundary";
import { FiAward } from "react-icons/fi";
-import { timeAgo, formatDate } from "@/utils/helpers";
+import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp";
export default function LeaderboardPage() {
const [tab, setTab] = useState("top_predictors");
@@ -45,7 +45,7 @@ export default function LeaderboardPage() {
{!loading && lastUpdated && (
- Updated {timeAgo(lastUpdated)}
+ Updated
)}
@@ -54,7 +54,7 @@ export default function LeaderboardPage() {
{lastUpdated && (
- Last updated: {formatDate(lastUpdated)}
+ Last updated:
)}
diff --git a/frontend/src/components/leaderboard/LeaderboardTable.tsx b/frontend/src/components/leaderboard/LeaderboardTable.tsx
index 02b3506..d3906ed 100644
--- a/frontend/src/components/leaderboard/LeaderboardTable.tsx
+++ b/frontend/src/components/leaderboard/LeaderboardTable.tsx
@@ -2,7 +2,7 @@ import React from "react";
import type { PlayerStats } from "@/types";
import PlayerRow from "./PlayerRow";
import { FiUser } from "react-icons/fi";
-import { formatDate } from "@/utils/helpers";
+import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp";
interface LeaderboardTableProps {
players: PlayerStats[];
@@ -49,7 +49,7 @@ export default function LeaderboardTable({
{lastUpdatedAt && (
- Last updated: {formatDate(lastUpdatedAt)}
+ Last updated:
)}
diff --git a/frontend/src/components/ui/LocalizedTimestamp.tsx b/frontend/src/components/ui/LocalizedTimestamp.tsx
new file mode 100644
index 0000000..84a73bf
--- /dev/null
+++ b/frontend/src/components/ui/LocalizedTimestamp.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import {
+ formatDate,
+ formatTime,
+ timeAgo,
+ toTimestampMs,
+} from "@/utils/helpers";
+
+const SERVER_LOCALE = "en-US";
+const SERVER_TIME_ZONE = "UTC";
+
+type TimestampMode = "date" | "time" | "relative";
+
+interface FormattingContext {
+ locale: string;
+ timeZone: string;
+}
+
+export interface LocalizedTimestampProps {
+ timestamp: number;
+ mode?: TimestampMode;
+ locale?: string;
+ timeZone?: string;
+ options?: Intl.DateTimeFormatOptions;
+ className?: string;
+ nowMs?: number;
+}
+
+/**
+ * Resolve the browser's preferred locale and IANA timezone. Kept behind a
+ * function so importing this module during SSR never touches browser globals.
+ */
+export function getBrowserFormattingContext(): FormattingContext {
+ if (typeof navigator === "undefined") {
+ return { locale: SERVER_LOCALE, timeZone: SERVER_TIME_ZONE };
+ }
+
+ const locale = navigator.languages?.[0] || navigator.language || SERVER_LOCALE;
+ let timeZone = SERVER_TIME_ZONE;
+
+ try {
+ timeZone =
+ new Intl.DateTimeFormat().resolvedOptions().timeZone || SERVER_TIME_ZONE;
+ } catch {
+ // Keep the deterministic UTC fallback when Intl data is unavailable.
+ }
+
+ return { locale, timeZone };
+}
+
+/**
+ * Hydration-safe timestamp renderer.
+ *
+ * Server output and the first client render both use en-US/UTC. After mount,
+ * the component switches to the viewer's actual locale and timezone. This
+ * avoids rendering server-local time into the HTML and prevents hydration
+ * mismatches for users outside the server timezone.
+ */
+export default function LocalizedTimestamp({
+ timestamp,
+ mode = "date",
+ locale,
+ timeZone,
+ options = {},
+ className,
+ nowMs,
+}: LocalizedTimestampProps) {
+ const [context, setContext] = useState(() => ({
+ locale: locale || SERVER_LOCALE,
+ timeZone: timeZone || SERVER_TIME_ZONE,
+ }));
+
+ useEffect(() => {
+ const browser = getBrowserFormattingContext();
+ const next = {
+ locale: locale || browser.locale,
+ timeZone: timeZone || browser.timeZone,
+ };
+
+ setContext((current) =>
+ current.locale === next.locale && current.timeZone === next.timeZone
+ ? current
+ : next
+ );
+ }, [locale, timeZone]);
+
+ const timestampMs = toTimestampMs(timestamp);
+ if (!Number.isFinite(timestampMs)) {
+ return —;
+ }
+
+ let value: string;
+ if (mode === "relative") {
+ value = timeAgo(timestamp, context.locale, nowMs);
+ } else {
+ const localizedOptions = { ...options, timeZone: context.timeZone };
+ value =
+ mode === "time"
+ ? formatTime(timestamp, context.locale, localizedOptions)
+ : formatDate(timestamp, context.locale, localizedOptions);
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts
index 3f28ea7..8ef1af1 100644
--- a/frontend/src/utils/helpers.ts
+++ b/frontend/src/utils/helpers.ts
@@ -1,7 +1,7 @@
// ── Pure Utility Functions ───────────────────────────────────────────────────
const STROOPS_PER_XLM = 10_000_000n;
-const MILLISECOND_TIMESTAMP_THRESHOLD = 4_102_444_800;
+const MILLISECOND_TIMESTAMP_THRESHOLD = 100_000_000_000;
const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
const INVALID_TIMESTAMP = "—";
@@ -34,16 +34,17 @@ export function truncateAddress(addr: string): string {
/** Validate a bet amount against the minimum and the user's balance. */
export function isValidAmount(amount: string, balance: number): boolean {
- const parsed = parseFloat(amount);
+ const parsed = Number.parseFloat(amount);
if (Number.isNaN(parsed) || parsed < 1) return false;
return parsed <= balance;
}
/** Return a human-readable duration until a Unix-seconds timestamp. */
export function timeUntil(timestamp: number): string {
- const now = Math.floor(Date.now() / 1000);
- const diff = timestamp - now;
+ if (!Number.isFinite(timestamp)) return "Ended";
+ const now = Math.floor(Date.now() / 1_000);
+ const diff = Math.floor(timestamp - now);
if (diff <= 0) return "Ended";
const days = Math.floor(diff / 86_400);
@@ -57,12 +58,19 @@ export function timeUntil(timestamp: number): string {
}
/**
- * 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.
+ * Values below 1e11 are treated as seconds; newer millisecond timestamps are
+ * already above that boundary. Invalid or out-of-range values return NaN.
*/
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;
}
const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
@@ -80,146 +88,83 @@ const TIME_OPTIONS: Intl.DateTimeFormatOptions = {
timeZoneName: "short",
};
-/**
- * Format a Unix timestamp (seconds) 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;
+function formatTimestampMs(
+ timestampMs: number,
+ locale: Intl.LocalesArgument | undefined,
+ options: Intl.DateTimeFormatOptions
+): string {
+ if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP;
+
+ try {
+ return new Intl.DateTimeFormat(locale, options).format(new Date(timestampMs));
+ } catch {
+ return INVALID_TIMESTAMP;
+ }
}
-/** Format a timestamp in the viewer's timezone, including its timezone label. */
+/** Format seconds or milliseconds in the requested locale and timezone. */
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;
- return new Intl.DateTimeFormat(locale, {
+ return formatTimestampMs(toTimestampMs(timestamp), locale, {
...DATE_TIME_OPTIONS,
...options,
- }).format(new Date(ms));
+ });
}
-/**
- * Format a Unix timestamp to a locale-aware time-only string.
- */
+/** Format only the local time portion, including its timezone label. */
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;
- return new Intl.DateTimeFormat(locale, {
+ return formatTimestampMs(toTimestampMs(timestamp), locale, {
...TIME_OPTIONS,
...options,
- }).format(new Date(ms));
-}
-
-/**
- * Format an event timestamp (milliseconds) to a locale-aware date+time string.
- * Use this for MarketEvent.timestamp — it is already in milliseconds.
- */
-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"
+ * Format an event timestamp that is explicitly supplied in milliseconds.
+ * This separate entry point prevents accidental double conversion.
*/
-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, {
- 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,
+export function formatEventTime(
+ timestampMs: number,
locale?: Intl.LocalesArgument,
options: Intl.DateTimeFormatOptions = {}
): string {
- const timestampMs = toTimestampMs(timestamp);
- if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP;
+ if (
+ !Number.isFinite(timestampMs) ||
+ timestampMs <= 0 ||
+ timestampMs > MAX_DATE_TIMESTAMP_MS
+ ) {
+ return INVALID_TIMESTAMP;
+ }
- return new Intl.DateTimeFormat(locale, {
- hour: "2-digit",
- minute: "2-digit",
- timeZoneName: "short",
+ return formatTimestampMs(timestampMs, locale, {
+ ...DATE_TIME_OPTIONS,
...options,
- }).format(new Date(timestampMs));
+ });
}
-/** Format a timestamp relative to now while accepting seconds or milliseconds. */
+/** Format a past or future timestamp relative to now. */
export function timeAgo(
timestamp: number,
- locale?: Intl.LocalesArgument
+ locale?: Intl.LocalesArgument,
+ nowMs: number = Date.now()
): string {
const timestampMs = toTimestampMs(timestamp);
- if (!Number.isFinite(timestampMs)) return INVALID_TIMESTAMP;
+ if (!Number.isFinite(timestampMs) || !Number.isFinite(nowMs)) {
+ return INVALID_TIMESTAMP;
+ }
- const diffSeconds = (timestampMs - Date.now()) / 1_000;
+ const diffSeconds = (timestampMs - nowMs) / 1_000;
const absoluteSeconds = Math.abs(diffSeconds);
if (absoluteSeconds < 5) return "just now";
- const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
+ const units: ReadonlyArray = [
["year", 31_536_000],
["month", 2_592_000],
["week", 604_800],
@@ -228,37 +173,23 @@ export function timeAgo(
["minute", 60],
["second", 1],
];
+
const [unit, unitSeconds] =
units.find(([, seconds]) => absoluteSeconds >= seconds) ?? units[6];
-
- const value =
- Math.sign(diffSeconds) * Math.round(absoluteSeconds / unitSeconds);
- return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
- value,
- unit
- );
-}
-
-/** 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",
- });
+ const magnitude = Math.max(1, Math.round(absoluteSeconds / unitSeconds));
+ const value = diffSeconds < 0 ? -magnitude : magnitude;
+
+ try {
+ return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
+ value,
+ unit
+ );
+ } catch {
+ return INVALID_TIMESTAMP;
+ }
}
-/** Calculate a winner's payout from a prediction market.
- *
- * payout = (userNetBet / winningSideTotal) × totalPool
- *
- * All values in XLM (not stroops).
- */
+/** Calculate a winner's proportional prediction-market payout. */
export function calculatePayout(
userNetBet: number,
winningSideTotal: number,
@@ -268,25 +199,18 @@ 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 percentages that always total 100. */
export function calculateOdds(
totalYes: number,
totalNo: number
): { yesPercent: number; noPercent: number } {
const total = totalYes + totalNo;
if (total <= 0) return { yesPercent: 50, noPercent: 50 };
+
const yesPercent = Math.round((totalYes / total) * 100);
return { yesPercent, noPercent: 100 - yesPercent };
}
-/**
- * Build a Stellar Expert explorer URL for transactions, accounts, or contracts.
- */
/** Convert basis points to a percentage string. */
export function bpsToPercent(bps: number): string {
return `${bps / 100}%`;
@@ -298,20 +222,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":
- return `${base}/tx/${id}`;
- case "account":
- return `${base}/account/${id}`;
- case "contract":
- return `${base}/contract/${id}`;
- default:
- return base;
- }
+ return `${base}/${type}/${id}`;
}