feat(converter): add shareable conversion links via query parameters
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user