feat: improve Bitcoin grantmaker inquiry UX
Some checks are pending
Deploy to GitHub Pages / deploy (push) Waiting to run

This commit is contained in:
SkateSpots 2026-11-01 20:37:35 +07:00
parent 9732743527
commit 1af3a5f6ef
3 changed files with 182 additions and 49 deletions

View file

@ -6683,11 +6683,12 @@ walletUi: {
our: 'OUR',
community: 'COMMUNITY',
heroDesc: 'SkateSpots is built by and for the community. Supporters, collaborators, partners and friends keeping this free and decentralized.',
grantsTab: 'Grants',
grantsSection: 'Bitcoin grantmakers',
grantsSectionDesc: 'For organizations that issue grants to open-source Bitcoin projects.',
grantsTitle: 'Open to sponsorships · Bitcoin grantmakers',
grantsDisclaimer: 'Want to donate to this project? Fill the form. Not to KYC us — to KYC YOU.',
grantsDisclaimerDesc: "This lane is only for grantmakers that KYC/KYB their grantees. If you're a skater who just wants to donate, use the Lightning, Silent Payments or Angor rails above — nobody needs your passport.",
grantsDisclaimer: 'Grantmaker inquiry. This lane is for organizations that already KYC/KYB their grantees.',
grantsDisclaimerDesc: "If you just want to donate, use the Lightning, Silent Payments or Angor rails above — no identity check.",
grantsOrg: 'Organization',
grantsContact: 'Contact (npub, email or handle)',
grantsWebsite: 'Website',
@ -6695,6 +6696,19 @@ walletUi: {
grantsSize: 'Grant size',
grantsKyc: 'KYC / KYB process',
grantsFund: 'What would you like to fund?',
grantsFundTitle: 'What a grant can fund',
grantsMoreDetails: 'Add grant details',
grantsOptional: 'Optional',
grantsRequired: 'Organization, contact and confirmation are required.',
grantsContactHint: 'Prefer an npub or NIP-05. Email or @handle is only reference text for the private DM.',
grantsWebsiteInvalid: 'Enter a valid http(s) website URL.',
grantsReview: 'Review message',
grantsReviewTitle: 'Review your private message',
grantsReviewDesc: 'Nothing has been sent. Check the message before opening the private DM.',
grantsOpenDm: 'Open private DM',
grantsCopy: 'Copy message',
grantsCopied: 'Copied',
grantsCopyFailed: 'Could not copy the message',
grantsAck: 'I confirm we are a grantmaker that KYC/KYB our grantees.',
grantsSend: 'Open grant conversation',
grantsNoKycTitle: 'No KYC of SkateSpots',

View file

@ -6683,11 +6683,12 @@ walletUi: {
our: 'NUESTRA',
community: 'COMUNIDAD',
heroDesc: 'SkateSpots lo construye y lo mantiene la comunidad. Patrocinadores, colaboradores, socios y amigos que lo mantienen libre y descentralizado.',
grantsTab: 'Ayudas',
grantsSection: 'Grantmakers de Bitcoin',
grantsSectionDesc: 'Para organizaciones que conceden ayudas a proyectos Bitcoin de código abierto.',
grantsTitle: 'Abierto a patrocinios · grantmakers de Bitcoin',
grantsDisclaimer: '¿Quieres donar a este proyecto? Rellena el formulario. No para hacernos KYC a nosotros, sino para hacerte KYC a TI.',
grantsDisclaimerDesc: 'Este canal es solo para grantmakers que hacen KYC/KYB a sus beneficiarios. Si eres un skater que solo quiere donar, usa las vías de Lightning, Silent Payments o Angor de arriba: nadie necesita tu pasaporte.',
grantsDisclaimer: 'Consulta para grantmakers. Este canal es para organizaciones que ya hacen KYC/KYB a sus beneficiarios.',
grantsDisclaimerDesc: 'Si solo quieres donar, usa las vías de Lightning, Silent Payments o Angor de arriba: sin comprobación de identidad.',
grantsOrg: 'Organización',
grantsContact: 'Contacto (npub, email o usuario)',
grantsWebsite: 'Sitio web',
@ -6695,6 +6696,19 @@ walletUi: {
grantsSize: 'Tamaño de la ayuda',
grantsKyc: 'Proceso de KYC / KYB',
grantsFund: '¿Qué te gustaría financiar?',
grantsFundTitle: 'Qué puede financiar una ayuda',
grantsMoreDetails: 'Añadir detalles de la ayuda',
grantsOptional: 'Opcional',
grantsRequired: 'La organización, el contacto y la confirmación son obligatorios.',
grantsContactHint: 'Mejor un npub o NIP-05. El email o @usuario solo es texto de referencia para el DM privado.',
grantsWebsiteInvalid: 'Introduce una URL http(s) válida.',
grantsReview: 'Revisar mensaje',
grantsReviewTitle: 'Revisa tu mensaje privado',
grantsReviewDesc: 'Todavía no se ha enviado nada. Revisa el mensaje antes de abrir el DM privado.',
grantsOpenDm: 'Abrir DM privado',
grantsCopy: 'Copiar mensaje',
grantsCopied: 'Copiado',
grantsCopyFailed: 'No se pudo copiar el mensaje',
grantsAck: 'Confirmo que somos un grantmaker que hace KYC/KYB a sus beneficiarios.',
grantsSend: 'Abrir conversación de ayuda',
grantsNoKycTitle: 'Sin KYC de SkateSpots',

View file

@ -1,8 +1,8 @@
import { useMemo, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck, Server, Globe, Bot, Code2, Briefcase, Megaphone, TrendingUp, Info, Lock, EyeOff } from 'lucide-react';
import { ExternalLink, Zap, Heart, Users, Handshake, Building2, Star, ShieldCheck, Server, Globe, Bot, Code2, Briefcase, Megaphone, TrendingUp, Info, Lock, EyeOff, Copy, Check, ChevronDown } from 'lucide-react';
import { SupportSkateSpots } from '@/components/SupportSkateSpots';
import { SilentPaymentsCard } from '@/components/SilentPaymentsCard';
import { AngorProjectCard, ANGOR_PROJECT_URL } from '@/components/AngorProjectCard';
@ -20,7 +20,9 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/hooks/useToast';
// Only count support received from this point (the project's public launch era).
@ -133,6 +135,7 @@ const PAYS_FOR = [
function GrantmakerForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const { toast } = useToast();
const owner = PLATFORM_OWNER_PUBKEYS[0];
const [org, setOrg] = useState('');
const [contact, setContact] = useState('');
@ -142,8 +145,13 @@ function GrantmakerForm() {
const [kyc, setKyc] = useState('');
const [fund, setFund] = useState('');
const [ack, setAck] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [copied, setCopied] = useState(false);
const canSend = !!org.trim() && !!contact.trim() && ack && !!owner;
const websiteError = website.trim() && !/^https?:\/\/\S+$/i.test(website.trim())
? t('walletUi.supporters.grantsWebsiteInvalid')
: '';
const canSend = !!org.trim() && !!contact.trim() && ack && !websiteError && !!owner;
const message = [
'Bitcoin grantmaker inquiry — SkateSpots',
'',
@ -158,6 +166,24 @@ function GrantmakerForm() {
'I confirm this is a grantmaker inquiry and we KYC/KYB our grantees.',
].filter(Boolean).join('\n');
const copyMessage = async () => {
try {
await navigator.clipboard.writeText(message);
setCopied(true);
toast({ title: t('walletUi.supporters.grantsCopied') });
window.setTimeout(() => setCopied(false), 2000);
} catch {
toast({ title: t('walletUi.supporters.grantsCopyFailed'), variant: 'destructive' });
}
};
const openDm = () => {
if (!canSend || !owner) return;
navigate(`/messages/${nip19.npubEncode(owner)}?message=${encodeURIComponent(message)}`);
};
const inputClass = 'border-[#f7931a]/25 bg-black/50 text-white placeholder:text-gray-500';
return (
<div className="rounded-2xl border-2 border-[#f7931a]/30 bg-gradient-to-br from-[#f7931a]/10 via-black to-transparent p-4 sm:p-6">
<div className="flex items-start gap-3">
@ -167,41 +193,93 @@ function GrantmakerForm() {
<div className="min-w-0">
<h3 className="text-lg font-black uppercase text-white">{t('walletUi.supporters.grantsTitle')}</h3>
<p className="mt-1 text-sm font-bold text-[#f7931a]">{t('walletUi.supporters.grantsDisclaimer')}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">{t('walletUi.supporters.grantsDisclaimerDesc')}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">{t('walletUi.supporters.grantsDisclaimerDesc')}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<p className="mt-4 text-[11px] font-bold uppercase tracking-wide text-[#f7931a]">
{t('walletUi.supporters.grantsRequired')}
</p>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="grant-org">{t('walletUi.supporters.grantsOrg')} *</Label>
<Input id="grant-org" value={org} onChange={(e) => setOrg(e.target.value)} placeholder="e.g. OpenSats" />
<Label htmlFor="grant-org" className="text-gray-200">{t('walletUi.supporters.grantsOrg')} *</Label>
<Input id="grant-org" value={org} onChange={(e) => setOrg(e.target.value)} placeholder="e.g. OpenSats" className={inputClass} required aria-required="true" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-contact">{t('walletUi.supporters.grantsContact')} *</Label>
<Input id="grant-contact" value={contact} onChange={(e) => setContact(e.target.value)} placeholder="npub, email or @handle" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-website">{t('walletUi.supporters.grantsWebsite')}</Label>
<Input id="grant-website" value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="https://…" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-program">{t('walletUi.supporters.grantsProgram')}</Label>
<Input id="grant-program" value={program} onChange={(e) => setProgram(e.target.value)} placeholder="e.g. Q1 2027 grants" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-size">{t('walletUi.supporters.grantsSize')}</Label>
<Input id="grant-size" value={grantSize} onChange={(e) => setGrantSize(e.target.value)} placeholder="e.g. 5–25M sats" />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-kyc">{t('walletUi.supporters.grantsKyc')}</Label>
<Input id="grant-kyc" value={kyc} onChange={(e) => setKyc(e.target.value)} placeholder="e.g. KYB + grantee KYC" />
<Label htmlFor="grant-contact" className="text-gray-200">{t('walletUi.supporters.grantsContact')} *</Label>
<Input id="grant-contact" value={contact} onChange={(e) => setContact(e.target.value)} placeholder="npub / NIP-05 / email / @handle" className={inputClass} required aria-required="true" />
<p className="text-[10px] leading-relaxed text-gray-500">{t('walletUi.supporters.grantsContactHint')}</p>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="grant-fund">{t('walletUi.supporters.grantsFund')}</Label>
<Textarea id="grant-fund" value={fund} onChange={(e) => setFund(e.target.value)} rows={3} placeholder="Relay, app development, translation, design, events…" />
<Label htmlFor="grant-website" className="text-gray-200">{t('walletUi.supporters.grantsWebsite')}</Label>
<Input
id="grant-website"
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://…"
className={inputClass}
aria-invalid={!!websiteError}
aria-describedby={websiteError ? 'grant-website-error' : undefined}
/>
{websiteError && <p id="grant-website-error" role="alert" className="text-[10px] text-red-400">{websiteError}</p>}
</div>
</div>
<div className="mt-4 rounded-xl border border-[#f7931a]/15 bg-black/25 p-3">
<p className="text-[10px] font-black uppercase tracking-widest text-[#f7931a]">{t('walletUi.supporters.grantsFundTitle')}</p>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-3">
{PAYS_FOR.slice(0, 3).map(({ icon: Icon, key, desc }) => (
<div key={key} className="flex items-start gap-2 text-xs text-gray-300">
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#f7931a]" />
<div>
<p className="font-bold text-white">{t(key)}</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-gray-500">{t(desc)}</p>
</div>
</div>
))}
</div>
</div>
<label className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-xl border border-[#f7931a]/20 bg-black/30 p-3 text-xs text-gray-300">
<input
type="checkbox"
checked={ack}
onChange={(e) => setAck(e.target.checked)}
className="mt-0.5 h-5 w-5 shrink-0 accent-[#f7931a]"
required
aria-required="true"
/>
<span>{t('walletUi.supporters.grantsAck')} *</span>
</label>
<details className="group mt-4 rounded-xl border border-white/10 bg-black/25">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-bold text-gray-300">
<ChevronDown className="h-4 w-4 text-[#f7931a] transition-transform group-open:rotate-180" />
{t('walletUi.supporters.grantsMoreDetails')}
<span className="ml-auto text-[10px] font-normal text-gray-500">{t('walletUi.supporters.grantsOptional')}</span>
</summary>
<div className="grid grid-cols-1 gap-3 border-t border-white/10 p-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="grant-program" className="text-gray-200">{t('walletUi.supporters.grantsProgram')}</Label>
<Input id="grant-program" value={program} onChange={(e) => setProgram(e.target.value)} placeholder="e.g. Q1 2027 grants" className={inputClass} />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-size" className="text-gray-200">{t('walletUi.supporters.grantsSize')}</Label>
<Input id="grant-size" value={grantSize} onChange={(e) => setGrantSize(e.target.value)} placeholder="e.g. 5–25M sats" className={inputClass} />
</div>
<div className="space-y-1.5">
<Label htmlFor="grant-kyc" className="text-gray-200">{t('walletUi.supporters.grantsKyc')}</Label>
<Input id="grant-kyc" value={kyc} onChange={(e) => setKyc(e.target.value)} placeholder="e.g. KYB + grantee KYC" className={inputClass} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="grant-fund" className="text-gray-200">{t('walletUi.supporters.grantsFund')}</Label>
<Textarea id="grant-fund" value={fund} onChange={(e) => setFund(e.target.value)} rows={3} placeholder="Relay, app development, translation, design, events…" className={inputClass} />
</div>
</div>
</details>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
{[
{ icon: ShieldCheck, title: t('walletUi.supporters.grantsNoKycTitle'), body: t('walletUi.supporters.grantsNoKycDesc') },
@ -212,37 +290,54 @@ function GrantmakerForm() {
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[#f7931a]" />
<div className="min-w-0">
<p className="text-[11px] font-bold text-white">{title}</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-gray-500">{body}</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-gray-400">{body}</p>
</div>
</div>
))}
</div>
<p className="mt-3 rounded-xl border border-[#f7931a]/15 bg-[#f7931a]/[0.04] px-3 py-2 text-[11px] leading-relaxed text-gray-500">
<p className="mt-3 rounded-xl border border-[#f7931a]/15 bg-[#f7931a]/[0.04] px-3 py-2 text-[11px] leading-relaxed text-gray-400">
{t('walletUi.supporters.grantsEthos')}
</p>
<label className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-xl border border-[#f7931a]/20 bg-black/30 p-3 text-xs text-gray-400">
<input type="checkbox" checked={ack} onChange={(e) => setAck(e.target.checked)} className="mt-0.5 h-4 w-4 accent-[#f7931a]" />
<span>{t('walletUi.supporters.grantsAck')}</span>
</label>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="button"
disabled={!canSend}
onClick={() => {
if (!canSend || !owner) return;
navigate(`/messages/${nip19.npubEncode(owner)}?message=${encodeURIComponent(message)}`);
}}
className="w-full justify-center bg-[#f7931a] font-bold text-black hover:bg-[#ffb347] sm:w-auto"
onClick={() => setReviewOpen(true)}
className="min-h-11 w-full justify-center bg-[#f7931a] font-bold text-black hover:bg-[#ffb347] disabled:opacity-50 sm:w-auto"
>
<Handshake className="mr-2 h-4 w-4" /> {t('walletUi.supporters.grantsSend')}
<Handshake className="mr-2 h-4 w-4" /> {t('walletUi.supporters.grantsReview')}
</Button>
<p className="flex items-center gap-1.5 text-[11px] text-gray-600">
<p className="flex items-center gap-1.5 text-[11px] text-gray-400" aria-live="polite">
<Info className="h-3 w-3 shrink-0" />
{t('walletUi.supporters.grantsNote')}
{canSend ? t('walletUi.supporters.grantsNote') : t('walletUi.supporters.grantsRequired')}
</p>
</div>
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
<DialogContent className="max-h-[85dvh] max-w-lg overflow-y-auto border-[#f7931a]/30 bg-zinc-950">
<DialogHeader>
<DialogTitle className="text-white">{t('walletUi.supporters.grantsReviewTitle')}</DialogTitle>
<DialogDescription>{t('walletUi.supporters.grantsReviewDesc')}</DialogDescription>
</DialogHeader>
<pre className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words rounded-xl border border-white/10 bg-black/40 p-3 text-xs leading-relaxed text-gray-300">
{message}
</pre>
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => setReviewOpen(false)} className="border-white/15">
{t('common.cancel')}
</Button>
<Button variant="outline" onClick={() => void copyMessage()} className="gap-2 border-white/15">
{copied ? <Check className="h-4 w-4 text-[#ccff00]" /> : <Copy className="h-4 w-4" />}
{copied ? t('walletUi.supporters.grantsCopied') : t('walletUi.supporters.grantsCopy')}
</Button>
<Button onClick={openDm} disabled={!canSend} className="gap-2 bg-[#f7931a] font-bold text-black hover:bg-[#ffb347]">
<Handshake className="h-4 w-4" /> {t('walletUi.supporters.grantsOpenDm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@ -559,7 +654,17 @@ export default function SupportersPage() {
const totalUsd = useSatsToUsd(allSats);
const { data: stats } = useGrowthStats();
const [showAll, setShowAll] = useState(false);
const [tab, setTab] = useState<'support' | 'backers' | 'community' | 'grants'>('support');
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const tab: 'support' | 'backers' | 'community' | 'grants' =
tabParam === 'backers' || tabParam === 'community' || tabParam === 'grants'
? tabParam
: 'support';
const setTab = (value: 'support' | 'backers' | 'community' | 'grants') => {
const next = new URLSearchParams(searchParams);
next.set('tab', value);
setSearchParams(next, { replace: true });
};
const [rail, setRail] = useState<'support-lightning' | 'support-silent' | 'support-angor'>('support-lightning');
// One ranked wall: Angor backers + Lightning zaps to the project, merged by npub.
@ -694,12 +799,12 @@ export default function SupportersPage() {
{ value: 'support', label: t('walletUi.supporters.support') },
{ value: 'backers', label: t('walletUi.supporters.backers') },
{ value: 'community', label: t('walletUi.supporters.community') },
{ value: 'grants', label: t('walletUi.supporters.grantsSection') },
{ value: 'grants', label: t('walletUi.supporters.grantsTab') },
].map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
className="h-auto min-h-10 whitespace-normal rounded-xl px-1.5 py-2 text-center text-[9px] font-black uppercase leading-tight tracking-wide data-[state=active]:bg-[#ccff00] data-[state=active]:text-black data-[state=active]:shadow-none sm:px-2 sm:text-[11px]"
className="h-auto min-h-11 whitespace-normal rounded-xl px-1.5 py-2 text-center text-[10px] font-black uppercase leading-tight tracking-wide data-[state=active]:bg-[#ccff00] data-[state=active]:text-black data-[state=active]:shadow-none sm:px-2 sm:text-[11px]"
>
{item.label}
</TabsTrigger>