feat(converter): add shareable conversion links via query parameters
This commit is contained in:
17
README.md
17
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.
|
||||
|
||||
27
app/page.tsx
27
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<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(
|
||||
(fromCode: string, toCode: string) => {
|
||||
@@ -34,6 +58,7 @@ export default function HomePage() {
|
||||
<ConverterCard
|
||||
forcedFromCode={selectedFromCode}
|
||||
forcedToCode={selectedToCode}
|
||||
forcedAmount={forcedAmount}
|
||||
onPairChange={handlePairChange}
|
||||
/>
|
||||
<InsightsSection
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BarChart3,
|
||||
Check,
|
||||
Copy,
|
||||
Link2,
|
||||
Loader2,
|
||||
MoveRight,
|
||||
RefreshCcw,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
formatUsdPrice,
|
||||
} from "@/lib/format";
|
||||
import { buildRateMap, convertAmount } from "@/lib/rates";
|
||||
import { buildConversionShareUrl } from "@/lib/share-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { validateAmount } from "@/lib/validation";
|
||||
|
||||
@@ -69,10 +71,43 @@ const MARKET_RANGE_LABELS: Record<MarketChartRange, string> = {
|
||||
interface ConverterCardProps {
|
||||
forcedFromCode?: string;
|
||||
forcedToCode?: string;
|
||||
forcedAmount?: string;
|
||||
onPairChange?: (fromCode: string, toCode: string) => void;
|
||||
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() {
|
||||
return (
|
||||
<Card className="border-border/70">
|
||||
@@ -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<MarketChartRange>("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,6 +801,23 @@ export function ConverterCard({
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
|
||||
Converted value
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void handleCopyShareLink()}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
isShareLinkCopied ? "Share link copied" : "Copy share link"
|
||||
}
|
||||
>
|
||||
{isShareLinkCopied ? (
|
||||
<Check className="h-3.5 w-3.5 text-cyan-200" />
|
||||
) : (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -748,6 +838,7 @@ export function ConverterCard({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{fromAsset && toAsset ? (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
|
||||
84
lib/share-link.ts
Normal file
84
lib/share-link.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user