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

@@ -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,25 +801,43 @@ export function ConverterCard({
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
Converted value
</p>
<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 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"
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>
{fromAsset && toAsset ? (
<AnimatePresence initial={false} mode="wait">