SkateSpots/src/components/ClinkPayButton.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
  key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
  ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
  trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
2026-10-01 23:57:20 +07:00

208 lines
7 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';
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 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: t('common.copied'), description: t('marketUi.clink.copiedBody') });
} catch {
toast({
title: t('marketUi.clink.copyFailed'),
description: t('marketUi.clink.copyFailedBody'),
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" />
{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>
<Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700">
<a href={nostrUri}>
<ExternalLink className="h-4 w-4" />
{t('marketUi.clink.openWallet')}
</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>
{t('marketUi.clink.footnote')}
</span>
</p>
</div>
</DialogContent>
</Dialog>
</>
);
}