225 lines
7.8 KiB
TypeScript
225 lines
7.8 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Zap, Copy, Check, ShieldCheck, HelpCircle } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { normalizeClinkCode, parseClinkOffer } from '@/lib/clink';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
interface ClinkPayButtonProps {
|
|
/** The CLINK Offer code (noffer1...) */
|
|
noffer: string;
|
|
/** Display name of the recipient, e.g. the provider or shop name */
|
|
recipientName?: string;
|
|
className?: string;
|
|
/** Button variant — defaults to a subtle outline so it stays secondary */
|
|
variant?: 'default' | 'outline' | 'secondary';
|
|
size?: 'default' | 'sm' | 'lg';
|
|
/**
|
|
* Friendlier button label. Defaults to "Tip in Bitcoin" — CLINK is jargon,
|
|
* so we lead with what it does and explain the tech in the tooltip/dialog.
|
|
*/
|
|
label?: string;
|
|
}
|
|
|
|
/**
|
|
* A self-custodial "Pay via CLINK" button.
|
|
*
|
|
* CLINK Offers (noffer1...) are Nostr-native, self-custodial static payment
|
|
* codes (see https://clinkme.dev). This component does NOT transact — it hands
|
|
* the code off to a CLINK-compatible wallet via QR, copy, or a nostr: deep link.
|
|
* It only renders when a valid noffer is present, so it is purely additive and
|
|
* never replaces the existing Lightning Address / zap flow.
|
|
*/
|
|
export function ClinkPayButton({
|
|
noffer,
|
|
recipientName,
|
|
className,
|
|
variant = 'outline',
|
|
size = 'default',
|
|
label,
|
|
}: ClinkPayButtonProps) {
|
|
const { t } = useTranslation();
|
|
const resolvedLabel = label ?? t('marketUi.clink.tip');
|
|
const [open, setOpen] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
|
const { toast } = useToast();
|
|
|
|
const offer = parseClinkOffer(noffer);
|
|
const code = offer?.raw ?? normalizeClinkCode(noffer);
|
|
|
|
useEffect(() => {
|
|
if (!open || !offer) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const { default: QRCode } = await import('qrcode');
|
|
// Spec: the QR payload is the RAW code — no `nostr:`/URI wrapper.
|
|
const url = await QRCode.toDataURL(code, {
|
|
width: 260,
|
|
margin: 2,
|
|
color: { dark: '#000000', light: '#ffffff' },
|
|
});
|
|
if (!cancelled) setQrDataUrl(url);
|
|
} catch {
|
|
if (!cancelled) setQrDataUrl(null);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, code, offer]);
|
|
|
|
// Don't render anything if the code is invalid — keeps the feature opt-in.
|
|
if (!offer) return null;
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(code);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
toast({ title: t('common.copied'), description: t('marketUi.clink.copiedBody') });
|
|
} catch {
|
|
toast({
|
|
title: t('marketUi.clink.copyFailed'),
|
|
description: t('marketUi.clink.copyFailedBody'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const shortCode = `${code.slice(0, 12)}…${code.slice(-6)}`;
|
|
|
|
// Pricing comes from the decoded TLV (0 fixed / 1 variable / 2 spontaneous).
|
|
const pricingLabel =
|
|
offer.priceType === 0
|
|
? offer.priceSats != null
|
|
? t('marketUi.clink.priceFixed', { amount: offer.priceSats.toLocaleString() })
|
|
: t('marketUi.clink.priceFixedShort')
|
|
: offer.priceType === 1
|
|
? offer.currency
|
|
? t('marketUi.clink.priceVariableCurrency', { currency: offer.currency })
|
|
: t('marketUi.clink.priceVariable')
|
|
: t('marketUi.clink.priceSpontaneous');
|
|
|
|
return (
|
|
<>
|
|
<div className={cn('flex items-center gap-1.5', className)}>
|
|
<Button
|
|
type="button"
|
|
variant={variant}
|
|
size={size}
|
|
className="flex-1 gap-2 border-indigo-500/40 text-indigo-600 hover:bg-indigo-500/10 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
<Zap className="h-4 w-4" />
|
|
{resolvedLabel}
|
|
</Button>
|
|
<TooltipProvider delayDuration={150}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
aria-label={t('marketUi.clink.whatIs')}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-indigo-500/10 hover:text-indigo-500"
|
|
>
|
|
<HelpCircle className="h-4 w-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-[220px] text-xs">
|
|
<p className="font-semibold">{t('marketUi.clink.tooltipTitle')}</p>
|
|
<p className="mt-1 text-muted-foreground">
|
|
{t('marketUi.clink.tooltipBody')}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Zap className="h-5 w-5 text-indigo-500" />
|
|
{resolvedLabel}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{recipientName
|
|
? t('marketUi.clink.descRecipient', { recipientName })
|
|
: t('marketUi.clink.descGeneric')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col items-center gap-4 py-2">
|
|
{qrDataUrl ? (
|
|
<div className="rounded-2xl bg-white p-3 shadow-sm ring-1 ring-black/5">
|
|
<img
|
|
src={qrDataUrl}
|
|
alt={t('marketUi.clink.qrAlt')}
|
|
className="h-[220px] w-[220px]"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-[220px] w-[220px] items-center justify-center rounded-2xl bg-muted">
|
|
<Zap className="h-8 w-8 animate-pulse text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex w-full items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
|
|
<code className="flex-1 truncate text-xs text-muted-foreground">
|
|
{shortCode}
|
|
</code>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0"
|
|
onClick={handleCopy}
|
|
aria-label={t('marketUi.clink.copyCode')}
|
|
>
|
|
{copied ? (
|
|
<Check className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-xs font-semibold text-indigo-500">{pricingLabel}</p>
|
|
|
|
<Button
|
|
type="button"
|
|
className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700"
|
|
onClick={handleCopy}
|
|
>
|
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
{t('marketUi.clink.copyToWallet')}
|
|
</Button>
|
|
|
|
<p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground">
|
|
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-indigo-500" />
|
|
<span>
|
|
{t('marketUi.clink.footnote')}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|