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')}
+
)}