SkateSpots/src/components/ClinkPayButton.tsx

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>
</>
);
}