From 2a165c7aad7b9a9a9d321228375e65bc748949d6 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 14:10:09 +0700 Subject: [PATCH] feat(nfc): write-to-NFC-tag for cards & passes (Web NFC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/NfcWriteButton.tsx | 58 +++++++++++++++++++++++ src/hooks/useRiderCard.ts | 5 +- src/i18n/en.ts | 6 +++ src/i18n/es.ts | 6 +++ src/lib/nfc.ts | 31 +++++++++++++ src/pages/RiderCardPage.tsx | 76 +++++++++++++++++-------------- 6 files changed, 147 insertions(+), 35 deletions(-) create mode 100644 src/components/NfcWriteButton.tsx create mode 100644 src/lib/nfc.ts diff --git a/src/components/NfcWriteButton.tsx b/src/components/NfcWriteButton.tsx new file mode 100644 index 0000000..99e7969 --- /dev/null +++ b/src/components/NfcWriteButton.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index 9d28350..2baa5d9 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -151,7 +151,9 @@ export function useCardProfile(pubkey: string | undefined) { }); } -export interface IssuedPass { id: string; +export interface IssuedPass { + id: string; + d: string; coord: string; holder?: string; accessLevel?: string; @@ -180,6 +182,7 @@ export function useIssuedPasses(pubkey?: string) { return events .map((ev) => ({ id: ev.id, + d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '', coord: ev.tags.find(([n]) => n === 'a')?.[1] ?? '', holder: ev.tags.find(([n]) => n === 'p')?.[1], accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1], diff --git a/src/i18n/en.ts b/src/i18n/en.ts index bfeafc5..b023179 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -6504,6 +6504,12 @@ walletUi: { organiser: 'Organiser', 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; diff --git a/src/i18n/es.ts b/src/i18n/es.ts index fceabf2..a0f4934 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -6504,6 +6504,12 @@ walletUi: { organiser: 'Organizador', 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; diff --git a/src/lib/nfc.ts b/src/lib/nfc.ts new file mode 100644 index 0000000..ebf3693 --- /dev/null +++ b/src/lib/nfc.ts @@ -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; +} +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 { + 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 }] }); +} diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index e51c6a1..38b4a78 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -21,6 +21,7 @@ import { nip19 } from 'nostr-tools'; import { LoginArea } from '@/components/auth/LoginArea'; import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard'; +import { NfcWriteButton } from '@/components/NfcWriteButton'; import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas'; import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard'; import { Button } from '@/components/ui/button'; @@ -1049,23 +1050,26 @@ export default function RiderCardPage() { {p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''} - {p.status === 'revoked' ? ( - {t('cardUi.parkRevoked')} - ) : ( - - )} + + + {p.status === 'revoked' ? ( + {t('cardUi.parkRevoked')} + ) : ( + + )} + ))} @@ -1139,23 +1143,26 @@ export default function RiderCardPage() { {p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'} - {p.status === 'revoked' ? ( - {t('cardUi.parkRevoked')} - ) : ( - - )} + + + {p.status === 'revoked' ? ( + {t('cardUi.parkRevoked')} + ) : ( + + )} + ))} @@ -1223,6 +1230,7 @@ export default function RiderCardPage() { > {t('labUi.rider.print')} + )}