feat(nfc): write-to-NFC-tag for cards & passes (Web NFC)
- lib/nfc.ts: isNfcSupported() + writeNfcUrl() via Web NFC (Chrome/Android), writing ONLY the verify URL (the same pointer the QR carries). - NfcWriteButton component (renders nothing where Web NFC is unavailable — iOS, desktop, Capacitor WebView — so there's no dead button). - Wired into: the venue's issued-pass list (issuer copies a tag per holder), the organiser's issued list, and the holder's 'My passes' view (+ Print). - i18n nfc.* EN/ES. Tags are passive and clonable like a QR; trust still comes from the relay signature, and /verify already handles taps.
This commit is contained in:
parent
7f9b5d9ccc
commit
2a165c7aad
6 changed files with 147 additions and 35 deletions
58
src/components/NfcWriteButton.tsx
Normal file
58
src/components/NfcWriteButton.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Nfc, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { isNfcSupported, writeNfcUrl } from '@/lib/nfc';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a card/pass verify URL to an NFC tag. Renders nothing where Web NFC
|
||||||
|
* isn't available (iOS, desktop, Capacitor WebView) — no dead button.
|
||||||
|
*/
|
||||||
|
export function NfcWriteButton({ url, className, compact = false }: { url?: string; className?: string; compact?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [writing, setWriting] = useState(false);
|
||||||
|
|
||||||
|
if (!isNfcSupported() || !url) return null;
|
||||||
|
|
||||||
|
const write = async () => {
|
||||||
|
setWriting(true);
|
||||||
|
try {
|
||||||
|
await writeNfcUrl(url);
|
||||||
|
toast({ title: t('nfc.written'), description: t('nfc.writtenDesc') });
|
||||||
|
} catch (e) {
|
||||||
|
toast({ title: t('nfc.failed'), description: String(e), variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setWriting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className={cn('h-7 shrink-0 gap-1 border-white/10 px-2 text-[10px]', className)}
|
||||||
|
disabled={writing}
|
||||||
|
onClick={() => void write()}
|
||||||
|
title={t('nfc.write')}
|
||||||
|
>
|
||||||
|
{writing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Nfc className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void write()}
|
||||||
|
disabled={writing}
|
||||||
|
className={cn('h-11 w-full gap-2 border-white/10 text-gray-200 hover:bg-white/10', className)}
|
||||||
|
>
|
||||||
|
{writing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Nfc className="h-4 w-4" />}
|
||||||
|
{t('nfc.write')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -151,7 +151,9 @@ export function useCardProfile(pubkey: string | undefined) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssuedPass { id: string;
|
export interface IssuedPass {
|
||||||
|
id: string;
|
||||||
|
d: string;
|
||||||
coord: string;
|
coord: string;
|
||||||
holder?: string;
|
holder?: string;
|
||||||
accessLevel?: string;
|
accessLevel?: string;
|
||||||
|
|
@ -180,6 +182,7 @@ export function useIssuedPasses(pubkey?: string) {
|
||||||
return events
|
return events
|
||||||
.map((ev) => ({
|
.map((ev) => ({
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
|
d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '',
|
||||||
coord: ev.tags.find(([n]) => n === 'a')?.[1] ?? '',
|
coord: ev.tags.find(([n]) => n === 'a')?.[1] ?? '',
|
||||||
holder: ev.tags.find(([n]) => n === 'p')?.[1],
|
holder: ev.tags.find(([n]) => n === 'p')?.[1],
|
||||||
accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1],
|
accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1],
|
||||||
|
|
|
||||||
|
|
@ -6504,6 +6504,12 @@ walletUi: {
|
||||||
organiser: 'Organiser',
|
organiser: 'Organiser',
|
||||||
verifiedOwner: 'Verified owner',
|
verifiedOwner: 'Verified owner',
|
||||||
},
|
},
|
||||||
|
nfc: {
|
||||||
|
write: 'Write to NFC tag',
|
||||||
|
written: 'Tag written',
|
||||||
|
writtenDesc: 'Tap the tag to a phone to open the verify page.',
|
||||||
|
failed: 'Could not write the tag',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|
|
||||||
|
|
@ -6504,6 +6504,12 @@ walletUi: {
|
||||||
organiser: 'Organizador',
|
organiser: 'Organizador',
|
||||||
verifiedOwner: 'Propietario verificado',
|
verifiedOwner: 'Propietario verificado',
|
||||||
},
|
},
|
||||||
|
nfc: {
|
||||||
|
write: 'Escribir en etiqueta NFC',
|
||||||
|
written: 'Etiqueta escrita',
|
||||||
|
writtenDesc: 'Acerca la etiqueta a un móvil para abrir la verificación.',
|
||||||
|
failed: 'No se pudo escribir la etiqueta',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default es;
|
export default es;
|
||||||
|
|
|
||||||
31
src/lib/nfc.ts
Normal file
31
src/lib/nfc.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* NFC tag writing via Web NFC (Chrome on Android over HTTPS).
|
||||||
|
*
|
||||||
|
* We write ONLY the verify URL to the tag — the same pointer the QR carries.
|
||||||
|
* A tag is passive and can be cloned, exactly like copying a QR; the trust still
|
||||||
|
* comes from the signature on the relay. iOS can't write tags (reading works
|
||||||
|
* everywhere by tapping), and Capacitor's WebView doesn't expose Web NFC, so the
|
||||||
|
* button only appears where it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface NdefWriter {
|
||||||
|
write(message: { records: Array<{ recordType: string; data: string }> }): Promise<void>;
|
||||||
|
}
|
||||||
|
type NdefCtor = new () => NdefWriter;
|
||||||
|
|
||||||
|
function ndefCtor(): NdefCtor | undefined {
|
||||||
|
if (typeof window === 'undefined') return undefined;
|
||||||
|
return (window as unknown as { NDEFReader?: NdefCtor }).NDEFReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNfcSupported(): boolean {
|
||||||
|
return !!ndefCtor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a URL record to an NFC tag. Rejects if unsupported or the write fails. */
|
||||||
|
export async function writeNfcUrl(url: string): Promise<void> {
|
||||||
|
const Ctor = ndefCtor();
|
||||||
|
if (!Ctor) throw new Error('NFC writing is not supported on this device/browser.');
|
||||||
|
const writer = new Ctor();
|
||||||
|
await writer.write({ records: [{ recordType: 'url', data: url }] });
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
|
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||||
|
import { NfcWriteButton } from '@/components/NfcWriteButton';
|
||||||
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
|
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
|
||||||
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
|
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -1049,23 +1050,26 @@ export default function RiderCardPage() {
|
||||||
<span className="truncate text-gray-400">
|
<span className="truncate text-gray-400">
|
||||||
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''}
|
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{p.status === 'revoked' ? (
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
<span className="shrink-0 font-bold uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
|
<NfcWriteButton compact url={user ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, p.d) : undefined} />
|
||||||
) : (
|
{p.status === 'revoked' ? (
|
||||||
<Button
|
<span className="font-bold uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
|
||||||
size="sm"
|
) : (
|
||||||
variant="outline"
|
<Button
|
||||||
className="h-7 shrink-0 border-white/10 text-[10px]"
|
size="sm"
|
||||||
disabled={revokingPass}
|
variant="outline"
|
||||||
onClick={() => {
|
className="h-7 shrink-0 border-white/10 text-[10px]"
|
||||||
void revokePass(p)
|
disabled={revokingPass}
|
||||||
.then(() => toast({ title: t('cardUi.parkRevoked') }))
|
onClick={() => {
|
||||||
.catch(() => toast({ title: t('cardUi.parkRevokeFailed'), variant: 'destructive' }));
|
void revokePass(p)
|
||||||
}}
|
.then(() => toast({ title: t('cardUi.parkRevoked') }))
|
||||||
>
|
.catch(() => toast({ title: t('cardUi.parkRevokeFailed'), variant: 'destructive' }));
|
||||||
{t('cardUi.parkRevoke')}
|
}}
|
||||||
</Button>
|
>
|
||||||
)}
|
{t('cardUi.parkRevoke')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1139,23 +1143,26 @@ export default function RiderCardPage() {
|
||||||
<span className="truncate text-gray-400">
|
<span className="truncate text-gray-400">
|
||||||
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}
|
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}
|
||||||
</span>
|
</span>
|
||||||
{p.status === 'revoked' ? (
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
<span className="shrink-0 font-bold uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
|
<NfcWriteButton compact url={user ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, p.d) : undefined} />
|
||||||
) : (
|
{p.status === 'revoked' ? (
|
||||||
<Button
|
<span className="font-bold uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
|
||||||
size="sm"
|
) : (
|
||||||
variant="outline"
|
<Button
|
||||||
className="h-7 shrink-0 border-white/10 text-[10px]"
|
size="sm"
|
||||||
disabled={revokingPass}
|
variant="outline"
|
||||||
onClick={() => {
|
className="h-7 shrink-0 border-white/10 text-[10px]"
|
||||||
void revokePass(p)
|
disabled={revokingPass}
|
||||||
.then(() => toast({ title: t('cardUi.parkRevoked') }))
|
onClick={() => {
|
||||||
.catch(() => toast({ title: t('cardUi.parkRevokeFailed'), variant: 'destructive' }));
|
void revokePass(p)
|
||||||
}}
|
.then(() => toast({ title: t('cardUi.parkRevoked') }))
|
||||||
>
|
.catch(() => toast({ title: t('cardUi.parkRevokeFailed'), variant: 'destructive' }));
|
||||||
{t('cardUi.parkRevoke')}
|
}}
|
||||||
</Button>
|
>
|
||||||
)}
|
{t('cardUi.parkRevoke')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1223,6 +1230,7 @@ export default function RiderCardPage() {
|
||||||
>
|
>
|
||||||
<Printer className="h-4 w-4" /> {t('labUi.rider.print')}
|
<Printer className="h-4 w-4" /> {t('labUi.rider.print')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<NfcWriteButton url={passCardData.verifyUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue