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`
|
- 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.
|
||||||
|
|||||||
27
app/page.tsx
27
app/page.tsx
@@ -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
|
||||||
|
|||||||
@@ -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,6 +801,23 @@ 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>
|
||||||
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -748,6 +838,7 @@ export function ConverterCard({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{fromAsset && toAsset ? (
|
{fromAsset && toAsset ? (
|
||||||
<AnimatePresence initial={false} mode="wait">
|
<AnimatePresence initial={false} mode="wait">
|
||||||
<motion.div
|
<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