From 228c133d7ea7f4f9e6d254cfe31c21d6a2f3bab1 Mon Sep 17 00:00:00 2001 From: Gbemi-programmer Date: Sun, 27 Sep 2026 17:39:14 +0100 Subject: [PATCH] feat(core): network fallback precedence, locale-safe formatting, lazy-import failure state, clipboard recovery Closes #136, #137, #138, #139 --- app/tools/[slug]/page.tsx | 22 ++++++- core/__tests__/amount.test.ts | 28 ++++++++ core/__tests__/date.test.ts | 17 +++++ core/__tests__/registry.lazy.test.ts | 40 ++++++++++++ core/format/amount.ts | 16 ++++- core/format/date.ts | 4 +- core/lib/__tests__/clipboard.test.tsx | 93 +++++++++++++++++++++++++++ core/lib/clipboard.ts | 60 ++++++++++++++++- core/network/NetworkProvider.tsx | 8 +-- core/network/__tests__/config.test.ts | 48 ++++++++++++++ core/network/config.ts | 69 ++++++++++++++++---- core/registry/registry.ts | 35 ++++++++-- core/ui/CopyableValue.tsx | 22 +++++-- 13 files changed, 427 insertions(+), 35 deletions(-) create mode 100644 core/lib/__tests__/clipboard.test.tsx create mode 100644 core/network/__tests__/config.test.ts diff --git a/app/tools/[slug]/page.tsx b/app/tools/[slug]/page.tsx index 3c3c98a..6b70ae0 100644 --- a/app/tools/[slug]/page.tsx +++ b/app/tools/[slug]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { findFeature } from "@/core/registry/registry"; +import { findFeature, loadFeaturePanel } from "@/core/registry/registry"; import { featureSlugs, findManifest } from "@/core/registry/manifests"; import { FeatureShell } from "@/core/ui/FeatureShell"; @@ -40,8 +40,24 @@ export default async function ToolPage({ params }: RouteParams) { if (!feature) notFound(); - const { manifest, load } = feature; - const Panel = await load(); + const { manifest } = feature; + const loaded = await loadFeaturePanel(feature); + + if (!loaded.ok) { + return ( + +
+

This tool could not be loaded.

+

+ A newer version may have been deployed or your connection dropped. Reload the page + to try again. +

+
+
+ ); + } + + const Panel = loaded.value; return ( diff --git a/core/__tests__/amount.test.ts b/core/__tests__/amount.test.ts index 3ad11c9..7f7d876 100644 --- a/core/__tests__/amount.test.ts +++ b/core/__tests__/amount.test.ts @@ -98,3 +98,31 @@ describe("formatInteger", () => { expect(formatInteger(1017700, "de-DE")).toBe("1.017.700"); }); }); + +describe("formatAmount locale edge cases", () => { + it("keeps zero, negatives and full stroop precision exact", () => { + expect(formatAmount("0", { locale: "en-US" })).toBe("0"); + expect(formatAmount("0.0000000", { locale: "en-US", trimZeros: false })).toBe("0.0000000"); + expect(formatAmount("0.0000001", { locale: "en-US" })).toBe("0.0000001"); + expect(formatAmount("-0.0000001", { locale: "en-US" })).toBe("-0.0000001"); + expect(formatAmount(-INT64_MAX, { locale: "en-US" })).toBe("-922,337,203,685.4775807"); + }); + + it("uses native digits for non-Latin locales without rounding", () => { + const arabic = formatAmount("1234.5000001", { locale: "ar-EG" }); + expect(arabic).toContain("٠٠٠٠٠١"); + expect(arabic).not.toMatch(/[0-9]/); + expect(formatAmount("12.3456789", { locale: "hi-IN-u-nu-deva" })).toBe("१२.३४५६७८९"); + }); + + it("keeps an RTL negative sign attached to the value", () => { + const shown = formatAmount("-1.5", { locale: "he-IL" }); + expect(shown).toMatch(/1\.5/); + expect(shown).toMatch(/-|−/); + }); + + it("falls back to the default locale for a malformed tag instead of throwing", () => { + expect(() => formatAmount("1.5", { locale: "not_a locale!" })).not.toThrow(); + expect(formatInteger(1234n, "!!")).toBe(formatInteger(1234n)); + }); +}); diff --git a/core/__tests__/date.test.ts b/core/__tests__/date.test.ts index be2e4c7..d74e8ad 100644 --- a/core/__tests__/date.test.ts +++ b/core/__tests__/date.test.ts @@ -32,3 +32,20 @@ describe("formatUnixSeconds", () => { expect(formatUnixSeconds("18446744073709551615", "en-US")).toBe("18446744073709551615"); }); }); + +describe("formatDateTime edge cases", () => { + it("returns invalid timestamps unchanged", () => { + expect(formatDateTime("not-a-date", "en-US")).toBe("not-a-date"); + expect(formatDateTime(Number.NaN, "en-US")).toBe("NaN"); + expect(formatDateTime(new Date("x"), "en-US")).toBe("Invalid Date"); + }); + + it("always formats in UTC regardless of locale", () => { + expect(formatDateTime("2024-01-01T00:30:00Z", "en-US")).toContain("UTC"); + expect(formatDateTime("2024-01-01T00:30:00Z", "ar-EG")).toContain("٢٠٢٤"); + }); + + it("falls back to the default locale for a malformed tag", () => { + expect(formatDateTime("2024-01-01T00:00:00Z", "@@bad")).toBe(formatDateTime("2024-01-01T00:00:00Z")); + }); +}); diff --git a/core/__tests__/registry.lazy.test.ts b/core/__tests__/registry.lazy.test.ts index 612a43e..83445d3 100644 --- a/core/__tests__/registry.lazy.test.ts +++ b/core/__tests__/registry.lazy.test.ts @@ -1,6 +1,13 @@ +import type { ComponentType } from "react"; import { describe, expect, it } from "vitest"; import { generatedFeatures } from "@/core/registry/registry.generated"; +import { findFeature, loadFeaturePanel } from "@/core/registry/registry"; +import type { FeatureEntry } from "@/core/registry/types"; + +function entryWith(load: FeatureEntry["load"]): FeatureEntry { + return { manifest: generatedFeatures[0].manifest, load }; +} describe("feature registry laziness", () => { it("exposes lazy loaders instead of eager panel modules", () => { @@ -9,4 +16,37 @@ describe("feature registry laziness", () => { expect(generatedFeatures[0]).toHaveProperty("load"); expect(generatedFeatures[0]).not.toHaveProperty("Panel"); }); + + it("returns undefined for unknown slugs", () => { + expect(findFeature("definitely-not-a-tool")).toBeUndefined(); + }); +}); + +describe("loadFeaturePanel", () => { + it("returns the panel when the import resolves", async () => { + const Panel: ComponentType = () => null; + const result = await loadFeaturePanel(entryWith(async () => Panel)); + expect(result).toEqual({ ok: true, value: Panel }); + }); + + it("turns a rejected chunk import into a typed, message-free failure", async () => { + const chunkError = new Error("Loading chunk 42 failed: https://cdn/x.js?token=abc"); + chunkError.name = "ChunkLoadError"; + const feature = entryWith(() => Promise.reject(chunkError)); + + const result = await loadFeaturePanel(feature); + + expect(result).toEqual({ + ok: false, + code: "load_failed", + detail: { slug: feature.manifest.slug, errorName: "ChunkLoadError" } + }); + expect(JSON.stringify(result)).not.toContain("token"); + }); + + it("handles non-Error rejections", async () => { + const result = await loadFeaturePanel(entryWith(() => Promise.reject("boom"))); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.detail?.errorName).toBe("UnknownError"); + }); }); diff --git a/core/format/amount.ts b/core/format/amount.ts index 93b8eb7..98a7816 100644 --- a/core/format/amount.ts +++ b/core/format/amount.ts @@ -19,6 +19,20 @@ export const INT64_MIN = -9_223_372_036_854_775_808n; export type Locales = string | readonly string[] | undefined; +/** + * Fallback policy: a malformed locale tag makes `Intl` throw, so it is dropped + * and the runtime default locale is used instead. An unsupported-but-valid tag + * is already negotiated down by `Intl` itself. Formatting never throws. + */ +export function safeLocales(locales: Locales): string | string[] | undefined { + if (locales === undefined) return undefined; + try { + return Intl.getCanonicalLocales(locales as string | string[]); + } catch { + return undefined; + } +} + /** * The only accepted input shape: ASCII digits, an optional leading `-`, and an * optional `.` followed by at least one digit. `.` is always the decimal @@ -111,7 +125,7 @@ function localeSymbols(locales: Locales): LocaleSymbols { const cached = symbolsCache.get(key); if (cached) return cached; - const requested = locales as string | string[] | undefined; + const requested = safeLocales(locales); const integer = new Intl.NumberFormat(requested, { maximumFractionDigits: 0 }); const parts = new Intl.NumberFormat(requested, { minimumFractionDigits: 1 }).formatToParts(-1); const integerIndex = parts.findIndex((part) => part.type === "integer"); diff --git a/core/format/date.ts b/core/format/date.ts index 688a9e1..2185aa8 100644 --- a/core/format/date.ts +++ b/core/format/date.ts @@ -1,4 +1,4 @@ -import type { Locales } from "@/core/format/amount"; +import { safeLocales, type Locales } from "@/core/format/amount"; /** * Formats ledger and request timestamps for display. @@ -14,7 +14,7 @@ function dateTimeFormat(locales: Locales): Intl.DateTimeFormat { const key = locales === undefined ? "" : [locales].flat().join(","); let formatter = formatters.get(key); if (!formatter) { - formatter = new Intl.DateTimeFormat(locales as string | string[] | undefined, { + formatter = new Intl.DateTimeFormat(safeLocales(locales), { dateStyle: "medium", timeStyle: "long", timeZone: "UTC" diff --git a/core/lib/__tests__/clipboard.test.tsx b/core/lib/__tests__/clipboard.test.tsx new file mode 100644 index 0000000..b9627ef --- /dev/null +++ b/core/lib/__tests__/clipboard.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClipboardError, copyText, tryCopyText } from "@/core/lib/clipboard"; +import { CopyableValue } from "@/core/ui/CopyableValue"; + +const HASH = "a".repeat(64); + +function mockClipboard(writeText?: (value: string) => Promise) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: writeText ? { writeText } : undefined + }); +} + +function mockExecCommand(result: boolean) { + const fn = vi.fn(() => result); + Object.defineProperty(document, "execCommand", { configurable: true, value: fn }); + return fn; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mockClipboard(); +}); + +describe("copyText", () => { + it("writes through the async clipboard API", async () => { + const writeText = vi.fn(async () => {}); + mockClipboard(writeText); + await copyText(HASH); + expect(writeText).toHaveBeenCalledWith(HASH); + }); + + it("throws a typed error when the API is missing", async () => { + mockClipboard(); + await expect(copyText(HASH)).rejects.toMatchObject({ reason: "unavailable" }); + }); + + it("throws a typed error when permission is denied", async () => { + mockClipboard(() => Promise.reject(new DOMException("denied", "NotAllowedError"))); + const error = await copyText(HASH).catch((e: unknown) => e); + expect(error).toBeInstanceOf(ClipboardError); + expect((error as ClipboardError).reason).toBe("denied"); + }); +}); + +describe("tryCopyText fallback selection", () => { + it("does not touch the legacy path when the API works", async () => { + mockClipboard(async () => {}); + const exec = mockExecCommand(true); + await expect(tryCopyText(HASH)).resolves.toBe("copied"); + expect(exec).not.toHaveBeenCalled(); + }); + + it("uses execCommand when the API is unavailable", async () => { + mockClipboard(); + const exec = mockExecCommand(true); + await expect(tryCopyText(HASH)).resolves.toBe("copied"); + expect(exec).toHaveBeenCalledWith("copy"); + }); + + it("reports the real failure when the legacy copy also fails", async () => { + mockClipboard(() => Promise.reject(new Error("nope"))); + mockExecCommand(false); + await expect(tryCopyText(HASH)).resolves.toBe("denied"); + mockClipboard(); + await expect(tryCopyText(HASH)).resolves.toBe("unavailable"); + }); +}); + +describe("CopyableValue", () => { + it("announces success", async () => { + mockClipboard(async () => {}); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy transaction hash" })); + expect(await screen.findByText("transaction hash copied to clipboard")).toBeTruthy(); + }); + + it("shows an actionable fallback with the full value instead of claiming success", async () => { + mockClipboard(() => Promise.reject(new Error("denied"))); + mockExecCommand(false); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy transaction hash" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Could not copy transaction hash"); + expect(screen.getByTestId("copy-fallback-value").textContent).toBe(HASH); + expect(screen.queryByText("transaction hash copied to clipboard")).toBeNull(); + }); +}); diff --git a/core/lib/clipboard.ts b/core/lib/clipboard.ts index 3670688..a84c5b2 100644 --- a/core/lib/clipboard.ts +++ b/core/lib/clipboard.ts @@ -1,7 +1,61 @@ +export type ClipboardFailure = "unavailable" | "denied"; + +/** Thrown by `copyText`; `reason` lets UI callers show the right guidance. */ +export class ClipboardError extends Error { + constructor(readonly reason: ClipboardFailure) { + super( + reason === "unavailable" + ? "Clipboard access is not available in this browser." + : "Clipboard access was denied." + ); + this.name = "ClipboardError"; + } +} + +/** Low-level boundary: throws a `ClipboardError` on any failure. */ export async function copyText(value: string): Promise { - if (typeof navigator === "undefined" || !navigator.clipboard) { - throw new Error("Clipboard access is not available in this browser."); + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + throw new ClipboardError("unavailable"); + } + + try { + await navigator.clipboard.writeText(value); + } catch { + throw new ClipboardError("denied"); } +} + +/** + * Legacy `execCommand("copy")` path for browsers without the async API or + * where it is denied. Reports success only when the browser says it copied. + */ +function legacyCopy(value: string): boolean { + if (typeof document === "undefined" || typeof document.execCommand !== "function") return false; + const area = document.createElement("textarea"); + area.value = value; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + try { + area.select(); + return document.execCommand("copy"); + } catch { + return false; + } finally { + area.remove(); + } +} + +export type CopyOutcome = "copied" | ClipboardFailure; - await navigator.clipboard.writeText(value); +/** UI-safe copy: never throws, and only reports "copied" when a copy happened. */ +export async function tryCopyText(value: string): Promise { + try { + await copyText(value); + return "copied"; + } catch (error) { + if (legacyCopy(value)) return "copied"; + return error instanceof ClipboardError ? error.reason : "denied"; + } } diff --git a/core/network/NetworkProvider.tsx b/core/network/NetworkProvider.tsx index e2feb6d..6c68c18 100644 --- a/core/network/NetworkProvider.tsx +++ b/core/network/NetworkProvider.tsx @@ -7,9 +7,10 @@ import { NETWORK_LABELS, NETWORK_PASSPHRASES, NETWORK_STORAGE_KEY, - SOROBAN_RPC_URLS + SOROBAN_RPC_URLS, + resolveNetwork } from "@/core/network/config"; -import { isStellarNetwork, type StellarNetwork } from "@/core/network/types"; +import type { StellarNetwork } from "@/core/network/types"; import { PRUNE_HORIZON_CLIENTS_DELAY_MS, PRUNE_HORIZON_CLIENTS_OP, @@ -50,8 +51,7 @@ function subscribe(onStoreChange: () => void): () => void { function getSnapshot(): StellarNetwork { try { - const stored = window.localStorage.getItem(NETWORK_STORAGE_KEY); - return isStellarNetwork(stored) ? stored : DEFAULT_NETWORK; + return resolveNetwork(window.localStorage.getItem(NETWORK_STORAGE_KEY), DEFAULT_NETWORK); } catch { // Private windows and blocked site data both throw here. return DEFAULT_NETWORK; diff --git a/core/network/__tests__/config.test.ts b/core/network/__tests__/config.test.ts new file mode 100644 index 0000000..72e9e82 --- /dev/null +++ b/core/network/__tests__/config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { redactEndpoint, resolveEndpoint, resolveNetwork } from "@/core/network/config"; + +describe("resolveNetwork precedence", () => { + it.each([ + ["mainnet", "testnet", "mainnet"], + ["testnet", "mainnet", "testnet"], + [null, "mainnet", "mainnet"], + ["futurenet", "mainnet", "mainnet"], + ["", undefined, "testnet"], + [null, "MAINNET", "testnet"], + [undefined, undefined, "testnet"] + ])("stored=%s env=%s → %s", (stored, env, expected) => { + expect(resolveNetwork(stored, env)).toBe(expected); + }); +}); + +describe("resolveEndpoint", () => { + const fallback = "https://horizon-testnet.stellar.org"; + + it("uses a valid http(s) override", () => { + expect(resolveEndpoint("https://my-horizon.example/", fallback)).toBe("https://my-horizon.example/"); + expect(resolveEndpoint(" http://localhost:8000 ", fallback)).toBe("http://localhost:8000"); + }); + + it.each([undefined, "", " ", "not a url", "horizon.stellar.org", "ftp://x.example", "javascript:alert(1)"])( + "falls back for %j", + (override) => { + expect(resolveEndpoint(override, fallback)).toBe(fallback); + } + ); + + it("rejects overrides carrying credentials", () => { + expect(resolveEndpoint("https://user:secret@rpc.example", fallback)).toBe(fallback); + }); +}); + +describe("redactEndpoint", () => { + it("drops query strings, fragments and credentials", () => { + const shown = redactEndpoint("https://u:p@rpc.example/v1?apiKey=SECRET#t=1"); + expect(shown).toBe("https://rpc.example/v1"); + expect(shown).not.toContain("SECRET"); + }); + + it("does not echo malformed values", () => { + expect(redactEndpoint("SSECRETKEY not a url")).toBe("[invalid endpoint]"); + }); +}); diff --git a/core/network/config.ts b/core/network/config.ts index c41f4fb..4835a93 100644 --- a/core/network/config.ts +++ b/core/network/config.ts @@ -1,23 +1,70 @@ import type { StellarNetwork } from "@/core/network/types"; import { isStellarNetwork } from "@/core/network/types"; -export const DEFAULT_NETWORK: StellarNetwork = isStellarNetwork( +/** + * Network precedence, highest first: + * 1. the user's persisted selection (if it is a known network), + * 2. the `NEXT_PUBLIC_STELLAR_NETWORK` override (if it is a known network), + * 3. testnet. + * Unknown values at any level are ignored rather than guessed at. + */ +export function resolveNetwork(stored: unknown, envOverride: unknown): StellarNetwork { + if (isStellarNetwork(stored)) return stored; + if (isStellarNetwork(envOverride)) return envOverride; + return "testnet"; +} + +export const DEFAULT_NETWORK: StellarNetwork = resolveNetwork( + undefined, process.env.NEXT_PUBLIC_STELLAR_NETWORK -) - ? process.env.NEXT_PUBLIC_STELLAR_NETWORK - : "testnet"; +); + +/** + * Uses `override` only when it is an http(s) URL without embedded credentials; + * anything else (empty, malformed, other scheme, `user:pass@`) falls back. + */ +export function resolveEndpoint(override: string | undefined, fallback: string): string { + if (!override?.trim()) return fallback; + try { + const url = new URL(override.trim()); + if (url.protocol !== "https:" && url.protocol !== "http:") return fallback; + if (url.username || url.password) return fallback; + return override.trim(); + } catch { + return fallback; + } +} + +/** + * Endpoint safe to show in diagnostics: origin and path only, so API keys in + * the query string, fragment or userinfo never reach the UI or logs. + */ +export function redactEndpoint(endpoint: string): string { + try { + const url = new URL(endpoint); + return `${url.origin}${url.pathname === "/" ? "" : url.pathname}`; + } catch { + return "[invalid endpoint]"; + } +} export const HORIZON_URLS: Record = { - testnet: - process.env.NEXT_PUBLIC_HORIZON_TESTNET_URL ?? "https://horizon-testnet.stellar.org", - mainnet: process.env.NEXT_PUBLIC_HORIZON_MAINNET_URL ?? "https://horizon.stellar.org" + testnet: resolveEndpoint( + process.env.NEXT_PUBLIC_HORIZON_TESTNET_URL, + "https://horizon-testnet.stellar.org" + ), + mainnet: resolveEndpoint(process.env.NEXT_PUBLIC_HORIZON_MAINNET_URL, "https://horizon.stellar.org") }; export const SOROBAN_RPC_URLS: Record = { - testnet: - process.env.NEXT_PUBLIC_SOROBAN_RPC_TESTNET_URL ?? "https://soroban-testnet.stellar.org", - mainnet: - process.env.NEXT_PUBLIC_SOROBAN_RPC_MAINNET_URL ?? "https://mainnet.sorobanrpc.com" + testnet: resolveEndpoint( + process.env.NEXT_PUBLIC_SOROBAN_RPC_TESTNET_URL, + "https://soroban-testnet.stellar.org" + ), + mainnet: resolveEndpoint( + process.env.NEXT_PUBLIC_SOROBAN_RPC_MAINNET_URL, + "https://mainnet.sorobanrpc.com" + ) }; export const NETWORK_PASSPHRASES: Record = { diff --git a/core/registry/registry.ts b/core/registry/registry.ts index 3201744..15a9cf0 100644 --- a/core/registry/registry.ts +++ b/core/registry/registry.ts @@ -1,7 +1,7 @@ -import { - generatedPanels, - generatedRegistrySchemaVersion -} from "@/core/registry/panels.generated"; +import type { ComponentType } from "react"; +import { generatedRegistrySchemaVersion } from "@/core/registry/panels.generated"; +import { generatedFeatures } from "@/core/registry/registry.generated"; +import { err, ok, type Result } from "@/core/result/result"; import { assertFeatureRegistryVersion } from "@/core/registry/schema"; import { findManifest } from "@/core/registry/manifests"; import type { FeatureEntry } from "@/core/registry/types"; @@ -23,4 +23,31 @@ export function findFeature(slug: string): FeatureEntry | undefined { return { manifest, load: feature.load }; } +export type FeatureLoadError = "load_failed"; + +/** Telemetry-safe context for a failed panel import: no stack, no raw message. */ +export interface FeatureLoadFailure { + slug: string; + errorName: string; +} + +/** + * Loads a panel, turning a rejected dynamic import (stale chunk after a + * deploy, offline, a slice that throws on evaluation) into a typed error so the + * route can show recovery UI instead of a blank page. Unknown slugs never + * reach here — `findFeature` returns undefined for them. + */ +export async function loadFeaturePanel( + feature: FeatureEntry +): Promise> { + try { + return ok(await feature.load()); + } catch (error) { + return err("load_failed", { + slug: feature.manifest.slug, + errorName: error instanceof Error ? error.name : "UnknownError" + }); + } +} + export { featureHref, featureSlugs, manifests, manifestsByCategory } from "@/core/registry/manifests"; diff --git a/core/ui/CopyableValue.tsx b/core/ui/CopyableValue.tsx index d2dffea..279b96c 100644 --- a/core/ui/CopyableValue.tsx +++ b/core/ui/CopyableValue.tsx @@ -3,7 +3,7 @@ import { Check, Copy } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/core/ui/Button"; -import { copyText } from "@/core/lib/clipboard"; +import { tryCopyText } from "@/core/lib/clipboard"; import { truncateMiddle } from "@/core/lib/strings"; import { cn } from "@/core/lib/cn"; @@ -31,14 +31,15 @@ export function CopyableValue({ }, []); const handleCopy = useCallback(async () => { - try { - await copyText(value); + const outcome = await tryCopyText(value); + if (timer.current) clearTimeout(timer.current); + if (outcome === "copied") { setState("copied"); - } catch { + timer.current = setTimeout(() => setState("idle"), 1600); + } else { + // Keep the recovery hint up until the next attempt so it can be read. setState("failed"); } - if (timer.current) clearTimeout(timer.current); - timer.current = setTimeout(() => setState("idle"), 1600); }, [value]); return ( @@ -63,8 +64,15 @@ export function CopyableValue({ {state === "copied" ? `${label} copied to clipboard` : ""} - {state === "failed" ? `Could not copy ${label}` : ""} + {state === "failed" ? ( + + Could not copy {label}. Select it and press Ctrl+C (⌘C on Mac):{" "} + + {value} + + + ) : null} ); }