SkateSpots/src/components/ClinkPayButton.tsx
shakespeare.diy 8056b02ad1 Crew band gig booking, hide treasury, CLINK on profiles, friendlier CLINK, remove desktop messages icon
- Add "punk" + "rap/hip-hop" crew vibe presets
- Hide Crew Treasury behind SHOW_CREW_TREASURY flag (detail page + form), code kept
- Improve crew band section into a dedicated card with a "Book them for a gig"
  action that deep-links to a Concert event hosted by the crew
- CreateEventPage: prefill event type + title from ?type= / ?title= params
- CLINK (a): expose CLINK tip code on user profiles (EditProfileForm field +
  "Tip in Bitcoin" button on profile view, stored as custom kind 0 field)
- CLINK (c): relabel "Pay via CLINK" -> "Tip in Bitcoin" with a "what's this?"
  tooltip explaining it in plain language
- Remove standalone desktop Messages icon (kept in the More menu)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-30 20:29:44 +07:00

208 lines
7.2 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Zap, Copy, Check, ExternalLink, 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 { isValidNoffer } from '@/lib/types';
import { cn } from '@/lib/utils';
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 = 'Tip in Bitcoin',
}: ClinkPayButtonProps) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const { toast } = useToast();
const valid = isValidNoffer(noffer);
const nostrUri = `nostr:${noffer}`;
useEffect(() => {
if (!open || !valid) return;
let cancelled = false;
(async () => {
try {
const { default: QRCode } = await import('qrcode');
const url = await QRCode.toDataURL(noffer.toUpperCase(), {
width: 260,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
if (!cancelled) setQrDataUrl(url);
} catch {
if (!cancelled) setQrDataUrl(null);
}
})();
return () => {
cancelled = true;
};
}, [open, noffer, valid]);
// Don't render anything if the code is invalid — keeps the feature opt-in.
if (!valid) return null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(noffer);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast({ title: 'Copied', description: 'CLINK code copied to clipboard.' });
} catch {
toast({
title: 'Copy failed',
description: 'Could not access the clipboard.',
variant: 'destructive',
});
}
};
const shortCode = `${noffer.slice(0, 12)}…${noffer.slice(-6)}`;
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" />
{label}
</Button>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="What is CLINK?"
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">Pay directly with Bitcoin over Nostr</p>
<p className="mt-1 text-muted-foreground">
CLINK is a self-custodial payment code — no app account, no middleman.
Scan it with a compatible Lightning wallet and the tip goes straight to
them.
</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" />
{label}
</DialogTitle>
<DialogDescription>
{recipientName
? `Send a self-custodial Bitcoin (Lightning) payment to ${recipientName} — no middleman.`
: 'Send a self-custodial Bitcoin (Lightning) payment over Nostr — no middleman.'}
</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="CLINK Offer QR code"
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="Copy CLINK code"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700">
<a href={nostrUri}>
<ExternalLink className="h-4 w-4" />
Open in CLINK wallet
</a>
</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>
Scan with a CLINK-compatible wallet (e.g. ShockWallet). No custodian,
no web server — the invoice is requested directly over Nostr.
</span>
</p>
</div>
</DialogContent>
</Dialog>
</>
);
}