From a45393ac00a3a26c62123e4f9e4373d8ebf4aad5 Mon Sep 17 00:00:00 2001 From: zvspany Date: Mon, 30 Mar 2026 17:42:08 +0200 Subject: [PATCH] feat(converter): add shareable conversion links via query parameters --- README.md | 17 ++++ app/page.tsx | 27 ++++- components/converter/converter-card.tsx | 129 ++++++++++++++++++++---- lib/share-link.ts | 84 +++++++++++++++ 4 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 lib/share-link.ts diff --git a/README.md b/README.md index 3b600a9..f40c247 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Modern currency and crypto converter built with Next.js 14, TypeScript, Tailwind - Fiat flags from `currency-flags` - Default pair: `USD -> BTC` - Instant conversion with swap action +- Shareable conversion links via query params (`amount`, `from`, `to`) - Current rate, inverse rate, and last update timestamp - Client-side validation for invalid/negative amounts - Loading, error, and empty states @@ -166,6 +167,22 @@ If empty, the app uses the local default (`/api/rates`). - Supported ranges: `24h`, `7d`, `30d`, `1y`, `all`. - Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `priceHistoryRange`, `priceHistory`, `updatedAt`. +## Shareable Links + +You can share the current converter state using URL query params: + +- `amount` +- `from` +- `to` + +Example: + +```text +/?amount=100&from=USD&to=BTC +``` + +The app safely parses these params on load and falls back to defaults when values are invalid or unsupported. + ## Architecture Notes - `app/api/rates/route.ts` is the single internal market endpoint for the frontend. diff --git a/app/page.tsx b/app/page.tsx index 04e20e4..9f30e59 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ConverterCard } from "@/components/converter/converter-card"; import { Hero } from "@/components/sections/hero"; import { InsightsSection } from "@/components/sections/insights-section"; +import { parseConversionShareParams } from "@/lib/share-link"; const DEFAULT_FROM = "USD"; const DEFAULT_TO = "BTC"; @@ -12,6 +13,29 @@ const DEFAULT_TO = "BTC"; export default function HomePage() { const [selectedFromCode, setSelectedFromCode] = useState(DEFAULT_FROM); const [selectedToCode, setSelectedToCode] = useState(DEFAULT_TO); + const [forcedAmount, setForcedAmount] = useState( + undefined, + ); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const parsed = parseConversionShareParams( + new URLSearchParams(window.location.search), + ); + + if (parsed.fromCode) { + setSelectedFromCode(parsed.fromCode); + } + + if (parsed.toCode) { + setSelectedToCode(parsed.toCode); + } + + setForcedAmount(parsed.amount); + }, []); const handleSelectPopularPair = useCallback( (fromCode: string, toCode: string) => { @@ -34,6 +58,7 @@ export default function HomePage() { = { interface ConverterCardProps { forcedFromCode?: string; forcedToCode?: string; + forcedAmount?: string; onPairChange?: (fromCode: string, toCode: string) => void; multiConversionCodes?: string[]; } +async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback below. + } + } + + if (typeof document === "undefined") { + return false; + } + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + textArea.style.pointerEvents = "none"; + textArea.style.left = "-9999px"; + document.body.appendChild(textArea); + textArea.select(); + textArea.setSelectionRange(0, textArea.value.length); + + try { + return document.execCommand("copy"); + } finally { + document.body.removeChild(textArea); + } +} + function ConverterSkeleton() { return ( @@ -143,6 +178,7 @@ function EmptyState() { export function ConverterCard({ forcedFromCode, forcedToCode, + forcedAmount, onPairChange, multiConversionCodes, }: ConverterCardProps) { @@ -153,6 +189,7 @@ export function ConverterCard({ const [fromCode, setFromCode] = useState(DEFAULT_FROM); const [toCode, setToCode] = useState(DEFAULT_TO); const [isCopied, setIsCopied] = useState(false); + const [isShareLinkCopied, setIsShareLinkCopied] = useState(false); const [marketRange, setMarketRange] = useState("24h"); const [swapAnimationStep, setSwapAnimationStep] = useState(0); const [pinStarAnimationStep, setPinStarAnimationStep] = useState(0); @@ -207,6 +244,18 @@ export function ConverterCard({ onPairChange?.(fromCode, toCode); }, [fromCode, toCode, onPairChange]); + useEffect(() => { + if (!forcedAmount) { + return; + } + + const parsed = validateAmount(forcedAmount); + + if (parsed.ok) { + setAmountInput(forcedAmount); + } + }, [forcedAmount]); + const inputValidation = validateAmount(amountInput); const debouncedValidation = validateAmount(debouncedAmount); @@ -307,6 +356,30 @@ export function ConverterCard({ } }; + const handleCopyShareLink = async () => { + if (typeof window === "undefined") { + return; + } + + const link = buildConversionShareUrl({ + origin: window.location.origin, + pathname: window.location.pathname, + amount: amountInput, + fromCode, + toCode, + }); + + const copied = await copyTextToClipboard(link); + + if (!copied) { + setIsShareLinkCopied(false); + return; + } + + setIsShareLinkCopied(true); + window.setTimeout(() => setIsShareLinkCopied(false), 1400); + }; + const displayUpdatedAt = useMemo(() => { if (!data) { return null; @@ -728,25 +801,43 @@ export function ConverterCard({

Converted value

- +
+ + +
{fromAsset && toAsset ? ( diff --git a/lib/share-link.ts b/lib/share-link.ts new file mode 100644 index 0000000..b9cfabc --- /dev/null +++ b/lib/share-link.ts @@ -0,0 +1,84 @@ +import { validateAmount } from "@/lib/validation"; + +interface QueryParamReader { + get(name: string): string | null; +} + +interface ConversionShareParams { + amount?: string; + fromCode?: string; + toCode?: string; +} + +const CODE_PATTERN = /^[A-Z0-9]{2,12}$/; + +function normalizeCurrencyCode(value: string | null): string | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim().toUpperCase(); + + if (!CODE_PATTERN.test(normalized)) { + return undefined; + } + + return normalized; +} + +function normalizeAmount(value: string | null): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim().replace(/\s+/g, ""); + + if (!trimmed || trimmed.length > 64) { + return undefined; + } + + return validateAmount(trimmed).ok ? trimmed : undefined; +} + +export function parseConversionShareParams( + searchParams: QueryParamReader, +): ConversionShareParams { + const amount = normalizeAmount(searchParams.get("amount")); + const fromCode = normalizeCurrencyCode(searchParams.get("from")); + let toCode = normalizeCurrencyCode(searchParams.get("to")); + + if (fromCode && toCode && fromCode === toCode) { + toCode = undefined; + } + + return { + amount, + fromCode, + toCode, + }; +} + +export function buildConversionShareUrl(input: { + origin: string; + pathname: string; + amount: string; + fromCode: string; + toCode: string; +}): string { + const url = new URL(input.pathname, input.origin); + const amount = normalizeAmount(input.amount) ?? "1"; + const fromCode = normalizeCurrencyCode(input.fromCode); + const toCode = normalizeCurrencyCode(input.toCode); + + url.searchParams.set("amount", amount); + + if (fromCode) { + url.searchParams.set("from", fromCode); + } + + if (toCode && toCode !== fromCode) { + url.searchParams.set("to", toCode); + } + + return url.toString(); +}