feat(converter): add shareable conversion links via query parameters

This commit is contained in:
2026-03-30 17:42:08 +02:00
parent 86fe4b516c
commit a45393ac00
4 changed files with 237 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ Modern currency and crypto converter built with Next.js 14, TypeScript, Tailwind
- Fiat flags from `currency-flags` - Fiat flags from `currency-flags`
- Default pair: `USD -> BTC` - Default pair: `USD -> BTC`
- Instant conversion with swap action - Instant conversion with swap action
- Shareable conversion links via query params (`amount`, `from`, `to`)
- Current rate, inverse rate, and last update timestamp - Current rate, inverse rate, and last update timestamp
- Client-side validation for invalid/negative amounts - Client-side validation for invalid/negative amounts
- Loading, error, and empty states - 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`. - Supported ranges: `24h`, `7d`, `30d`, `1y`, `all`.
- Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `priceHistoryRange`, `priceHistory`, `updatedAt`. - 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 ## Architecture Notes
- `app/api/rates/route.ts` is the single internal market endpoint for the frontend. - `app/api/rates/route.ts` is the single internal market endpoint for the frontend.

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ConverterCard } from "@/components/converter/converter-card"; import { ConverterCard } from "@/components/converter/converter-card";
import { Hero } from "@/components/sections/hero"; import { Hero } from "@/components/sections/hero";
import { InsightsSection } from "@/components/sections/insights-section"; import { InsightsSection } from "@/components/sections/insights-section";
import { parseConversionShareParams } from "@/lib/share-link";
const DEFAULT_FROM = "USD"; const DEFAULT_FROM = "USD";
const DEFAULT_TO = "BTC"; const DEFAULT_TO = "BTC";
@@ -12,6 +13,29 @@ const DEFAULT_TO = "BTC";
export default function HomePage() { export default function HomePage() {
const [selectedFromCode, setSelectedFromCode] = useState(DEFAULT_FROM); const [selectedFromCode, setSelectedFromCode] = useState(DEFAULT_FROM);
const [selectedToCode, setSelectedToCode] = useState(DEFAULT_TO); const [selectedToCode, setSelectedToCode] = useState(DEFAULT_TO);
const [forcedAmount, setForcedAmount] = useState<string | undefined>(
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( const handleSelectPopularPair = useCallback(
(fromCode: string, toCode: string) => { (fromCode: string, toCode: string) => {
@@ -34,6 +58,7 @@ export default function HomePage() {
<ConverterCard <ConverterCard
forcedFromCode={selectedFromCode} forcedFromCode={selectedFromCode}
forcedToCode={selectedToCode} forcedToCode={selectedToCode}
forcedAmount={forcedAmount}
onPairChange={handlePairChange} onPairChange={handlePairChange}
/> />
<InsightsSection <InsightsSection

View File

@@ -8,6 +8,7 @@ import {
BarChart3, BarChart3,
Check, Check,
Copy, Copy,
Link2,
Loader2, Loader2,
MoveRight, MoveRight,
RefreshCcw, RefreshCcw,
@@ -49,6 +50,7 @@ import {
formatUsdPrice, formatUsdPrice,
} from "@/lib/format"; } from "@/lib/format";
import { buildRateMap, convertAmount } from "@/lib/rates"; import { buildRateMap, convertAmount } from "@/lib/rates";
import { buildConversionShareUrl } from "@/lib/share-link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { validateAmount } from "@/lib/validation"; import { validateAmount } from "@/lib/validation";
@@ -69,10 +71,43 @@ const MARKET_RANGE_LABELS: Record<MarketChartRange, string> = {
interface ConverterCardProps { interface ConverterCardProps {
forcedFromCode?: string; forcedFromCode?: string;
forcedToCode?: string; forcedToCode?: string;
forcedAmount?: string;
onPairChange?: (fromCode: string, toCode: string) => void; onPairChange?: (fromCode: string, toCode: string) => void;
multiConversionCodes?: string[]; multiConversionCodes?: string[];
} }
async function copyTextToClipboard(text: string): Promise<boolean> {
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() { function ConverterSkeleton() {
return ( return (
<Card className="border-border/70"> <Card className="border-border/70">
@@ -143,6 +178,7 @@ function EmptyState() {
export function ConverterCard({ export function ConverterCard({
forcedFromCode, forcedFromCode,
forcedToCode, forcedToCode,
forcedAmount,
onPairChange, onPairChange,
multiConversionCodes, multiConversionCodes,
}: ConverterCardProps) { }: ConverterCardProps) {
@@ -153,6 +189,7 @@ export function ConverterCard({
const [fromCode, setFromCode] = useState(DEFAULT_FROM); const [fromCode, setFromCode] = useState(DEFAULT_FROM);
const [toCode, setToCode] = useState(DEFAULT_TO); const [toCode, setToCode] = useState(DEFAULT_TO);
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [isShareLinkCopied, setIsShareLinkCopied] = useState(false);
const [marketRange, setMarketRange] = useState<MarketChartRange>("24h"); const [marketRange, setMarketRange] = useState<MarketChartRange>("24h");
const [swapAnimationStep, setSwapAnimationStep] = useState(0); const [swapAnimationStep, setSwapAnimationStep] = useState(0);
const [pinStarAnimationStep, setPinStarAnimationStep] = useState(0); const [pinStarAnimationStep, setPinStarAnimationStep] = useState(0);
@@ -207,6 +244,18 @@ export function ConverterCard({
onPairChange?.(fromCode, toCode); onPairChange?.(fromCode, toCode);
}, [fromCode, toCode, onPairChange]); }, [fromCode, toCode, onPairChange]);
useEffect(() => {
if (!forcedAmount) {
return;
}
const parsed = validateAmount(forcedAmount);
if (parsed.ok) {
setAmountInput(forcedAmount);
}
}, [forcedAmount]);
const inputValidation = validateAmount(amountInput); const inputValidation = validateAmount(amountInput);
const debouncedValidation = validateAmount(debouncedAmount); 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(() => { const displayUpdatedAt = useMemo(() => {
if (!data) { if (!data) {
return null; return null;
@@ -728,25 +801,43 @@ export function ConverterCard({
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
Converted value Converted value
</p> </p>
<Button <div className="flex items-center gap-1">
type="button" <Button
variant="ghost" type="button"
size="icon" variant="ghost"
onClick={() => void handleCopyConvertedValue()} size="icon"
disabled={convertedValue === null || !toAsset} onClick={() => void handleCopyShareLink()}
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={ aria-label={
isCopied isShareLinkCopied ? "Share link copied" : "Copy share link"
? "Converted value copied" }
: "Copy converted value to clipboard" >
} {isShareLinkCopied ? (
> <Check className="h-3.5 w-3.5 text-cyan-200" />
{isCopied ? ( ) : (
<Check className="h-3.5 w-3.5 text-cyan-200" /> <Link2 className="h-3.5 w-3.5" />
) : ( )}
<Copy className="h-3.5 w-3.5" /> </Button>
)} <Button
</Button> type="button"
variant="ghost"
size="icon"
onClick={() => void handleCopyConvertedValue()}
disabled={convertedValue === null || !toAsset}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
aria-label={
isCopied
? "Converted value copied"
: "Copy converted value to clipboard"
}
>
{isCopied ? (
<Check className="h-3.5 w-3.5 text-cyan-200" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div> </div>
{fromAsset && toAsset ? ( {fromAsset && toAsset ? (
<AnimatePresence initial={false} mode="wait"> <AnimatePresence initial={false} mode="wait">

84
lib/share-link.ts Normal file
View File

@@ -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();
}