feat(card): make Rider Cards real & verifiable
- Publish the Rider Card as an addressable event (kind 32271, d=rider-card) and hydrate the editor from it, so customization persists across devices. - New routes: /card/:npub (public read-only card) and /verify/:naddr (signature check + park venue binding). - Park Access Cards can bind to the issuer's own spot (a tag 30818:...) and the verify page confirms the issuer owns the venue. - QR on the card (+ canvas export) now encodes the verify URL; the real QR renders in the React card too (was a placeholder / broken profile path). - Extracted shared card visuals to src/components/RiderCard.tsx; helpers in src/lib/riderCard.ts; hooks useRiderCard/useRiderBadges. - Documented in NIP.md; i18n cardUi namespace (EN/ES); smoke test covers the new routes.
This commit is contained in:
parent
0b796440f1
commit
dbb9ea98c0
12 changed files with 1075 additions and 344 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -63,6 +63,20 @@ session with **no other memory of this project**. So:
|
||||||
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
||||||
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
|
`AddPhotoToSpot`, replacing the old one-file-at-a-time inputs.
|
||||||
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
|
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
|
||||||
|
- **Rider Cards (2026-10-02) — now REAL + verifiable.** `32271` **Rider Card**
|
||||||
|
(addressable, `d=rider-card`) — the skater signs their own card customization
|
||||||
|
(theme/style/skills/sponsors/rides-for/show-flags; `v:1` marks show flags
|
||||||
|
explicit). `35679` Park Access Card (venue-bound via an optional
|
||||||
|
`["a","30818:<parkPubkey>:<d>"]` tag). `1293` witnessed skill badge. Routes:
|
||||||
|
`/card` (editor) · **`/#/card/<npub>`** (public, read-only) ·
|
||||||
|
**`/#/verify/<naddr>`** (signature check + venue binding). Helpers in
|
||||||
|
`src/lib/riderCard.ts` (`parseRiderCard`/`riderCardTags`/`cardVerifyUrl`),
|
||||||
|
hooks `useRiderCard`/`useRiderBadges`, visuals in `src/components/RiderCard.tsx`
|
||||||
|
(ONE React renderer; the canvas export in `RiderCardPage` is the print one).
|
||||||
|
⚠️ **A valid signature proves the SIGNER — not skill, and not access rights**
|
||||||
|
beyond what the venue issued; the card says so. A pass with no `a` tag verifies
|
||||||
|
as a signature but is shown **not venue-bound** (any key can mint one). The QR
|
||||||
|
now encodes the verify URL (was a profile path that HashRouter ignored).
|
||||||
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
||||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||||||
|
|
|
||||||
56
NIP.md
56
NIP.md
|
|
@ -1497,11 +1497,19 @@ Issued by a private skatepark owner to a specific rider, granting access rights.
|
||||||
["access-level", "<Full Access|Supervised|Beginner Zone|Street Only|Bowl Only|Members Only>"],
|
["access-level", "<Full Access|Supervised|Beginner Zone|Street Only|Bowl Only|Members Only>"],
|
||||||
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
|
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
|
||||||
["valid-thru", "<YYYY-MM-DD>"],
|
["valid-thru", "<YYYY-MM-DD>"],
|
||||||
|
["a", "30818:<park-pubkey>:<park-d>"],
|
||||||
["alt", "Skatepark Access Card"]
|
["alt", "Skatepark Access Card"]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Venue binding (`a`, optional but recommended):** when present, it points at the
|
||||||
|
park's own spot (kind 30818). The `/verify` page fetches that spot and confirms
|
||||||
|
its **author pubkey equals the pass's signer** — i.e. the pass was issued by the
|
||||||
|
key that owns the venue. A pass with no `a` tag still verifies as a signature,
|
||||||
|
but is shown as **not bound to a venue** (any key can mint one), so a park that
|
||||||
|
cares about authenticity should always set it.
|
||||||
|
|
||||||
### Card Themes
|
### Card Themes
|
||||||
|
|
||||||
| Value | Description |
|
| Value | Description |
|
||||||
|
|
@ -1513,31 +1521,59 @@ Issued by a private skatepark owner to a specific rider, granting access rights.
|
||||||
| `midnight` | Deep Blue |
|
| `midnight` | Deep Blue |
|
||||||
| `sunset` | Orange & Pink |
|
| `sunset` | Orange & Pink |
|
||||||
|
|
||||||
### Rider License (client-side only)
|
### Rider Card (kind 32271, addressable)
|
||||||
|
|
||||||
The personal Rider License is generated client-side from the user's kind 0 profile metadata and on-chain stats. It is not a separate Nostr event — it renders from existing data:
|
The personal **Rider License** is published as an addressable event (kind 32271,
|
||||||
|
`d = "rider-card"`) signed by the skater themselves. It persists across devices
|
||||||
|
and can be linked to and verified. Name, avatar and NIP-05 still come from the
|
||||||
|
author's kind 0; on-chain stats are derived live:
|
||||||
|
|
||||||
- Name, avatar, NIP-05: from kind 0
|
|
||||||
- Spots added: count of kind 30818 authored by the user
|
- Spots added: count of kind 30818 authored by the user
|
||||||
- Zaps received: count of kind 9735 targeting the user
|
- Zaps received: count of kind 9735 targeting the user
|
||||||
- Events created: count of kind 31923 authored by the user
|
- Events created: count of kind 31923 authored by the user
|
||||||
- Park cards held: count of kind 35679 with `#p` = user
|
|
||||||
- Witnessed skill badges: count of kind 1293 with `#p` = user
|
- Witnessed skill badges: count of kind 1293 with `#p` = user
|
||||||
|
|
||||||
The Rider License includes two categories of skill badges:
|
```json
|
||||||
- **Self-claimed** — selected by the rider themselves, shown in theme color
|
{
|
||||||
- **Witnessed** — awarded by others (kind 1293), shown in gold ⭐
|
"kind": 32271,
|
||||||
|
"content": "",
|
||||||
|
"tags": [
|
||||||
|
["d", "rider-card"],
|
||||||
|
["t", "rider-card"],
|
||||||
|
["theme", "<thug|lime|chrome|wood|midnight|sunset>"],
|
||||||
|
["v", "1"],
|
||||||
|
["style", "<Street|Park|Vert|Bowl|DIY|All-terrain>"],
|
||||||
|
["skill", "<skill-id>"],
|
||||||
|
["sponsor", "<text>"],
|
||||||
|
["rides-for", "<text>"],
|
||||||
|
["show", "spots"],
|
||||||
|
["show", "zaps"],
|
||||||
|
["show", "events"],
|
||||||
|
["alt", "SkateSpots Rider Card"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
The "Rides For" field displays the local shop the rider represents, distinct from sponsors (brands).
|
- `v:1` marks the `show` flags as explicit, so hiding a stat persists (an event
|
||||||
|
with no `v`/`show` tags defaults all three to visible).
|
||||||
|
- The card shows **self-claimed** skills (`skill` tags) in the theme colour and
|
||||||
|
**witnessed** badges (kind 1293) in gold ⭐.
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
The QR code on printed Park Access cards encodes the Nostr address (`naddr`) of the issuing event. Verifiers scan the QR, fetch the event from any relay, and confirm the issuer's signature.
|
Both card types are verifiable. The QR on a printed card encodes the `naddr` of
|
||||||
|
its event, which opens the in-app verify page:
|
||||||
|
|
||||||
```
|
```
|
||||||
URL format: https://skate-spots.org/verify/<naddr>
|
URL format: https://skate-spots.org/#/verify/<naddr>
|
||||||
|
Public card: https://skate-spots.org/#/card/<npub>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`/verify` fetches the event, runs `verifyEvent()` on the signature, and for a
|
||||||
|
park pass also checks the venue binding (above). It is deliberately honest: a
|
||||||
|
valid signature proves **who signed** the card — never skill, and never access
|
||||||
|
rights beyond what the venue itself issued.
|
||||||
|
|
||||||
## Skill Badge Awards (kind 1293)
|
## Skill Badge Awards (kind 1293)
|
||||||
|
|
||||||
Anyone who witnesses a rider performing a trick can award them a cryptographically signed skill badge. This creates an on-chain proof of skill — unfakeable because the witness's Nostr key signs the event.
|
Anyone who witnesses a rider performing a trick can award them a cryptographically signed skill badge. This creates an on-chain proof of skill — unfakeable because the witness's Nostr key signs the event.
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,9 @@ const ROUTES = [
|
||||||
'/mints',
|
'/mints',
|
||||||
'/app-shop',
|
'/app-shop',
|
||||||
'/review',
|
'/review',
|
||||||
|
'/card',
|
||||||
|
'/card/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
|
||||||
|
'/verify/notarealnaddr',
|
||||||
'/messages/group/abc123',
|
'/messages/group/abc123',
|
||||||
];
|
];
|
||||||
const problems = [];
|
const problems = [];
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ const LicensePage = lazyWithRetry(() => import("./pages/LicensePage"));
|
||||||
const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage"));
|
const PrivacyPage = lazyWithRetry(() => import("./pages/PrivacyPage"));
|
||||||
const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage"));
|
const SupportersPage = lazyWithRetry(() => import("./pages/SupportersPage"));
|
||||||
const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
const RiderCardPage = lazyWithRetry(() => import("./pages/RiderCardPage"));
|
||||||
|
const PublicCardPage = lazyWithRetry(() => import("./pages/PublicCardPage"));
|
||||||
|
const VerifyCardPage = lazyWithRetry(() => import("./pages/VerifyCardPage"));
|
||||||
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
|
const AmberCallbackPage = lazyWithRetry(() => import("./pages/AmberCallbackPage"));
|
||||||
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
|
const MarketplacePage = lazyWithRetry(() => import("./pages/MarketplacePage"));
|
||||||
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
|
const NewsletterPage = lazyWithRetry(() => import("./pages/NewsletterPage"));
|
||||||
|
|
@ -168,6 +170,8 @@ export function AppRouter() {
|
||||||
<Route path="/stash" element={<StashPage />} />
|
<Route path="/stash" element={<StashPage />} />
|
||||||
{/* Info pages */}
|
{/* Info pages */}
|
||||||
<Route path="/card" element={<RiderCardPage />} />
|
<Route path="/card" element={<RiderCardPage />} />
|
||||||
|
<Route path="/card/:npub" element={<PublicCardPage />} />
|
||||||
|
<Route path="/verify/:naddr" element={<VerifyCardPage />} />
|
||||||
<Route path="/license" element={<LicensePage />} />
|
<Route path="/license" element={<LicensePage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
<Route path="/supporters" element={<SupportersPage />} />
|
<Route path="/supporters" element={<SupportersPage />} />
|
||||||
|
|
|
||||||
291
src/components/RiderCard.tsx
Normal file
291
src/components/RiderCard.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
/**
|
||||||
|
* RiderCard — the visual card used by both the `/card` editor and the public
|
||||||
|
* `/card/:npub` view. Extracted so there is ONE React rendering of the card
|
||||||
|
* (the canvas export in `RiderCardPage` remains the print renderer).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { SKILL_OPTIONS, shortNpub, type ThemeId } from '@/lib/riderCard';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface BadgeAward {
|
||||||
|
witnessName: string;
|
||||||
|
witnessPubkey: string;
|
||||||
|
skill: string;
|
||||||
|
spotName?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardData {
|
||||||
|
type: 'rider' | 'park';
|
||||||
|
theme: ThemeId;
|
||||||
|
holographic: boolean;
|
||||||
|
// Rider
|
||||||
|
name: string;
|
||||||
|
nip05?: string;
|
||||||
|
avatar?: string;
|
||||||
|
pubkey: string;
|
||||||
|
npub: string;
|
||||||
|
style: string;
|
||||||
|
skills: string[];
|
||||||
|
sponsors: string;
|
||||||
|
ridesFor: string;
|
||||||
|
showSpots: boolean;
|
||||||
|
showZaps: boolean;
|
||||||
|
showEvents: boolean;
|
||||||
|
spotsAdded: number;
|
||||||
|
zapsReceived: number;
|
||||||
|
eventsCreated: number;
|
||||||
|
memberYear: string;
|
||||||
|
receivedBadges: BadgeAward[];
|
||||||
|
/** When set, the printed QR opens the verify page for this card. */
|
||||||
|
verifyUrl?: string;
|
||||||
|
// Park
|
||||||
|
issuerName: string;
|
||||||
|
recipientName: string;
|
||||||
|
accessLevel: string;
|
||||||
|
validThru: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real QR (lazy-imported so canvas API access stays out of module load). */
|
||||||
|
function CardQr({ url }: { url?: string }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!url) { setSrc(null); return; }
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const QRCode = (await import('qrcode')).default;
|
||||||
|
const dataUrl = await QRCode.toDataURL(url, {
|
||||||
|
width: 88,
|
||||||
|
margin: 0,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
color: { dark: '#000000', light: '#ffffff' },
|
||||||
|
});
|
||||||
|
if (!cancelled) setSrc(dataUrl);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setSrc(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[3px] flex-shrink-0" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
|
||||||
|
{src
|
||||||
|
? <img src={src} alt="QR" width={38} height={38} style={{ display: 'block', width: '38px', height: '38px' }} />
|
||||||
|
: <div style={{ width: '38px', height: '38px' }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card visual: Rider front ─────────────────────────────────────────────────
|
||||||
|
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
|
||||||
|
function RiderFront({ d }: { d: CardData }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const witnessedBadges = d.receivedBadges.slice(0, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||||
|
|
||||||
|
{/* Row 1: header */}
|
||||||
|
<div className="flex items-start justify-between" style={{ height: '26px' }}>
|
||||||
|
<div>
|
||||||
|
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
|
||||||
|
🛹 SkateSpots
|
||||||
|
</div>
|
||||||
|
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewLicense')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
|
||||||
|
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
|
||||||
|
{shortNpub(d.pubkey)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[5px] card-muted mt-[1px]">{t('labUi.rider.previewSince', { year: d.memberYear })}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: main body */}
|
||||||
|
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex-shrink-0 flex flex-col gap-[4px]">
|
||||||
|
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||||
|
{d.avatar ? (
|
||||||
|
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-[2px]">
|
||||||
|
{d.showSpots && (
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="text-[5px] card-muted">{t('labUi.pyramid.spots')}</span>
|
||||||
|
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{d.showZaps && (
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="text-[5px] card-muted">⚡</span>
|
||||||
|
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{d.showEvents && (
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="text-[5px] card-muted">{t('labUi.rider.previewEvts')}</span>
|
||||||
|
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity block */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||||
|
<div className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</div>
|
||||||
|
{d.nip05 && (
|
||||||
|
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
|
||||||
|
)}
|
||||||
|
{d.style && (
|
||||||
|
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
|
||||||
|
{d.style}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{d.ridesFor && (
|
||||||
|
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
|
||||||
|
)}
|
||||||
|
{d.sponsors && (
|
||||||
|
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
|
||||||
|
)}
|
||||||
|
{/* Skills — self-claimed then witnessed (gold) */}
|
||||||
|
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
||||||
|
{d.skills.slice(0, 4).map(sk => {
|
||||||
|
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
||||||
|
return s ? (
|
||||||
|
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
|
||||||
|
{s.icon} {s.label}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
{witnessedBadges.map((b, i) => {
|
||||||
|
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
||||||
|
return s ? (
|
||||||
|
<span
|
||||||
|
key={`w${i}`}
|
||||||
|
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
|
||||||
|
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
|
||||||
|
>
|
||||||
|
⭐ {s.label}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR */}
|
||||||
|
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||||
|
<CardQr url={d.verifyUrl} />
|
||||||
|
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||||
|
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
||||||
|
style={{ bottom: '6px' }}
|
||||||
|
>
|
||||||
|
<span className="card-muted text-[5px] uppercase tracking-widest">
|
||||||
|
skate-spots.org · Powered by Nostr
|
||||||
|
</span>
|
||||||
|
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{shortNpub(d.pubkey)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card visual: Park Access front ──────────────────────────────────────────
|
||||||
|
function ParkFront({ d }: { d: CardData }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between" style={{ height: '26px' }}>
|
||||||
|
<div>
|
||||||
|
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
|
||||||
|
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
|
||||||
|
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
||||||
|
{d.avatar ? (
|
||||||
|
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
||||||
|
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || t('labUi.rider.previewMemberName')}</div>
|
||||||
|
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
|
||||||
|
<div className="text-[5.5px] card-muted leading-none">{t('labUi.rider.previewIssuedBy')}</div>
|
||||||
|
{d.validThru && (
|
||||||
|
<div className="flex items-center gap-1 mt-[3px]">
|
||||||
|
<span className="text-[5.5px] card-muted uppercase">{t('labUi.rider.previewValidThru')}</span>
|
||||||
|
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
||||||
|
<CardQr url={d.verifyUrl} />
|
||||||
|
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">
|
||||||
|
{d.verifyUrl ? t('labUi.rider.previewScan') : t('cardUi.notPublished')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
||||||
|
style={{ bottom: '6px' }}
|
||||||
|
>
|
||||||
|
<span className="card-muted text-[5px] uppercase tracking-widest">
|
||||||
|
skate-spots.org · Powered by Nostr
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="card-muted text-[5px] uppercase tracking-widest">{t('labUi.rider.previewNostrVerified')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
|
||||||
|
const themeClass = `card-theme-${d.theme}`;
|
||||||
|
const holoClass = d.holographic ? 'rider-card-holo' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={printRef}
|
||||||
|
className={cn('rider-card', themeClass, holoClass, 'select-none')}
|
||||||
|
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }}
|
||||||
|
>
|
||||||
|
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
|
||||||
|
{d.type === 'rider' && <RiderFront d={d} />}
|
||||||
|
{d.type === 'park' && <ParkFront d={d} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/hooks/useRiderCard.ts
Normal file
80
src/hooks/useRiderCard.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* useRiderCard — fetch a skater's published Rider Card + witnessed skill badges.
|
||||||
|
*
|
||||||
|
* The Rider Card is an addressable event (kind 32271, d="rider-card") signed by
|
||||||
|
* the skater themselves, so it persists across devices and can be verified.
|
||||||
|
*/
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import {
|
||||||
|
RIDER_CARD_KIND,
|
||||||
|
RIDER_CARD_D,
|
||||||
|
SKILL_BADGE_KIND,
|
||||||
|
parseRiderCard,
|
||||||
|
type RiderCardConfig,
|
||||||
|
} from '@/lib/riderCard';
|
||||||
|
|
||||||
|
export interface RiderCardResult {
|
||||||
|
config: RiderCardConfig | null;
|
||||||
|
event: NostrEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRiderCard(pubkey: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
return useQuery<RiderCardResult>({
|
||||||
|
queryKey: ['rider-card', pubkey],
|
||||||
|
enabled: !!pubkey,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
if (!pubkey) return { config: null, event: null };
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [RIDER_CARD_KIND], authors: [pubkey], '#d': [RIDER_CARD_D], limit: 1 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const event = events[0] ?? null;
|
||||||
|
return { config: event ? parseRiderCard(event) : null, event };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiderBadge {
|
||||||
|
skill: string;
|
||||||
|
witnessPubkey: string;
|
||||||
|
spotName?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skill badges witnessed for a rider (kind 1293, `#p` = rider). */
|
||||||
|
export function useRiderBadges(pubkey: string | undefined) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
return useQuery<RiderBadge[]>({
|
||||||
|
queryKey: ['rider-badges', pubkey],
|
||||||
|
enabled: !!pubkey,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
if (!pubkey) return [];
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 100 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return events
|
||||||
|
.filter((e) => e.tags.some(([k]) => k === 'skill'))
|
||||||
|
.flatMap((e) => {
|
||||||
|
const spot = e.tags.find(([k]) => k === 'spot')?.[1];
|
||||||
|
return e.tags
|
||||||
|
.filter(([k]) => k === 'skill')
|
||||||
|
.map(([, skill]) => ({
|
||||||
|
skill,
|
||||||
|
witnessPubkey: e.pubkey,
|
||||||
|
spotName: spot,
|
||||||
|
timestamp: e.created_at,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6290,6 +6290,48 @@ walletUi: {
|
||||||
deleteDescGeneric: 'This item will be removed from your stash. This can’t be undone.',
|
deleteDescGeneric: 'This item will be removed from your stash. This can’t be undone.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cardUi: {
|
||||||
|
save: 'Save card to Nostr',
|
||||||
|
saved: 'Card saved',
|
||||||
|
savedDesc: 'Your card is now signed and verifiable.',
|
||||||
|
saveFailed: 'Could not save the card',
|
||||||
|
notPublished: 'Not published',
|
||||||
|
venue: 'Linked venue (optional)',
|
||||||
|
venueNone: 'No venue',
|
||||||
|
venueHint: 'Pick one of your own spots to bind this pass to a venue. A verifier will confirm you own it.',
|
||||||
|
verify: {
|
||||||
|
checking: 'Checking the card…',
|
||||||
|
notFound: 'Card not found',
|
||||||
|
notFoundDesc: 'No matching event was found on the relays. It may have been deleted, or the link is wrong.',
|
||||||
|
invalid: 'Signature invalid',
|
||||||
|
invalidDesc: 'This event exists but its signature does not verify. Do not trust it.',
|
||||||
|
authentic: 'Authentic',
|
||||||
|
authenticDesc: 'The issuer’s signature is valid.',
|
||||||
|
kind: 'Type',
|
||||||
|
kindPark: 'Park Access Card',
|
||||||
|
kindRider: 'Rider Card',
|
||||||
|
issuedBy: 'Issued by',
|
||||||
|
accessLevel: 'Access level',
|
||||||
|
validThru: 'Valid thru',
|
||||||
|
selfIssued: 'Status',
|
||||||
|
selfIssuedDesc: 'Self-issued identity card',
|
||||||
|
venue: 'Venue',
|
||||||
|
venueVerified: 'Verified — this venue’s owner key matches the issuer.',
|
||||||
|
venueMismatch: 'This pass names a venue that is not owned by the issuer’s key.',
|
||||||
|
venueNone: 'Not bound to a venue.',
|
||||||
|
viewCard: 'View the card',
|
||||||
|
disclaimer: 'A valid signature proves who signed this card — it is not proof of skill or of access rights beyond what the venue issued.',
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
invalid: 'That doesn’t look like a valid card link.',
|
||||||
|
createYourOwn: 'Create your own card',
|
||||||
|
editYourCard: 'Edit your card',
|
||||||
|
backToEditor: 'Back to the editor',
|
||||||
|
backHome: 'Back home',
|
||||||
|
notCustomised: 'This skater hasn’t customised their card yet — this is the default look.',
|
||||||
|
verifyNote: 'Scan the QR on the card to verify it.',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|
|
||||||
|
|
@ -6290,6 +6290,48 @@ walletUi: {
|
||||||
deleteDescGeneric: 'Este artículo se quitará de tu stash. Esto no se puede deshacer.',
|
deleteDescGeneric: 'Este artículo se quitará de tu stash. Esto no se puede deshacer.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
cardUi: {
|
||||||
|
save: 'Guardar carné en Nostr',
|
||||||
|
saved: 'Carné guardado',
|
||||||
|
savedDesc: 'Tu carné está firmado y se puede verificar.',
|
||||||
|
saveFailed: 'No se pudo guardar el carné',
|
||||||
|
notPublished: 'Sin publicar',
|
||||||
|
venue: 'Sede vinculada (opcional)',
|
||||||
|
venueNone: 'Sin sede',
|
||||||
|
venueHint: 'Elige uno de tus propios spots para vincular este pase a una sede. Un verificador confirmará que es tuya.',
|
||||||
|
verify: {
|
||||||
|
checking: 'Comprobando el carné…',
|
||||||
|
notFound: 'Carné no encontrado',
|
||||||
|
notFoundDesc: 'No se ha encontrado ningún evento en los relés. Puede que se haya eliminado o que el enlace sea incorrecto.',
|
||||||
|
invalid: 'Firma no válida',
|
||||||
|
invalidDesc: 'Este evento existe pero su firma no se verifica. No te fíes.',
|
||||||
|
authentic: 'Auténtico',
|
||||||
|
authenticDesc: 'La firma del emisor es válida.',
|
||||||
|
kind: 'Tipo',
|
||||||
|
kindPark: 'Pase de acceso a parque',
|
||||||
|
kindRider: 'Carné de rider',
|
||||||
|
issuedBy: 'Emitido por',
|
||||||
|
accessLevel: 'Nivel de acceso',
|
||||||
|
validThru: 'Válido hasta',
|
||||||
|
selfIssued: 'Estado',
|
||||||
|
selfIssuedDesc: 'Carné de identidad autoemitido',
|
||||||
|
venue: 'Sede',
|
||||||
|
venueVerified: 'Verificado — la clave del dueño de esta sede coincide con el emisor.',
|
||||||
|
venueMismatch: 'Este pase nombra una sede que no pertenece a la clave del emisor.',
|
||||||
|
venueNone: 'Sin vincular a ninguna sede.',
|
||||||
|
viewCard: 'Ver el carné',
|
||||||
|
disclaimer: 'Una firma válida demuestra quién firmó este carné — no es prueba de habilidad ni de derechos de acceso más allá de lo que emitió la sede.',
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
invalid: 'Eso no parece un enlace de carné válido.',
|
||||||
|
createYourOwn: 'Crea tu propio carné',
|
||||||
|
editYourCard: 'Edita tu carné',
|
||||||
|
backToEditor: 'Volver al editor',
|
||||||
|
backHome: 'Volver al inicio',
|
||||||
|
notCustomised: 'Este skater aún no ha personalizado su carné — esta es la apariencia por defecto.',
|
||||||
|
verifyNote: 'Escanea el QR del carné para verificarlo.',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default es;
|
export default es;
|
||||||
|
|
|
||||||
142
src/lib/riderCard.ts
Normal file
142
src/lib/riderCard.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/**
|
||||||
|
* Rider Card / Park Access Card — shared constants + helpers.
|
||||||
|
*
|
||||||
|
* The personal Rider License used to be client-side only (see NIP.md → Rider
|
||||||
|
* Cards). It is now published as an **addressable** event so a skater's card
|
||||||
|
* customization persists across devices and can be linked to and verified.
|
||||||
|
*
|
||||||
|
* Kinds:
|
||||||
|
* 32271 — Rider Card (addressable, d = "rider-card"). The skater signs it.
|
||||||
|
* 35679 — Park Access Card (addressable). A venue/owner signs it for a rider.
|
||||||
|
* 1293 — Skill badge (regular). A witness signs it for a rider.
|
||||||
|
*/
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
export const RIDER_CARD_KIND = 32271;
|
||||||
|
export const RIDER_CARD_D = 'rider-card';
|
||||||
|
export const PARK_ACCESS_CARD_KIND = 35679;
|
||||||
|
export const SKILL_BADGE_KIND = 1293;
|
||||||
|
|
||||||
|
/** Public site origin used for QR / verify links (the app is a HashRouter). */
|
||||||
|
export const CARD_ORIGIN = 'https://skate-spots.org';
|
||||||
|
|
||||||
|
export interface RiderCardConfig {
|
||||||
|
theme: string;
|
||||||
|
style: string;
|
||||||
|
skills: string[];
|
||||||
|
sponsors: string;
|
||||||
|
ridesFor: string;
|
||||||
|
showSpots: boolean;
|
||||||
|
showZaps: boolean;
|
||||||
|
showEvents: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
|
||||||
|
theme: 'thug',
|
||||||
|
style: 'Street',
|
||||||
|
skills: [],
|
||||||
|
sponsors: '',
|
||||||
|
ridesFor: '',
|
||||||
|
showSpots: true,
|
||||||
|
showZaps: true,
|
||||||
|
showEvents: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read a saved Rider Card event back into editable state. */
|
||||||
|
export function parseRiderCard(event: NostrEvent): RiderCardConfig {
|
||||||
|
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1] ?? '';
|
||||||
|
// `v` marks the "show" flags as explicitly set — older/absent events default
|
||||||
|
// all three to visible (so a card saved before a stat was ever hidden is fine).
|
||||||
|
const versioned = event.tags.some(([n]) => n === 'v');
|
||||||
|
const show = new Set(event.tags.filter(([n]) => n === 'show').map(([, v]) => v));
|
||||||
|
return {
|
||||||
|
theme: tag('theme') || DEFAULT_RIDER_CARD.theme,
|
||||||
|
style: tag('style') || DEFAULT_RIDER_CARD.style,
|
||||||
|
skills: event.tags.filter(([n]) => n === 'skill').map(([, v]) => v).filter(Boolean),
|
||||||
|
sponsors: tag('sponsor'),
|
||||||
|
ridesFor: tag('rides-for'),
|
||||||
|
showSpots: versioned ? show.has('spots') : true,
|
||||||
|
showZaps: versioned ? show.has('zaps') : true,
|
||||||
|
showEvents: versioned ? show.has('events') : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the tags for the addressable Rider Card event. */
|
||||||
|
export function riderCardTags(c: RiderCardConfig): string[][] {
|
||||||
|
const tags: string[][] = [
|
||||||
|
['d', RIDER_CARD_D],
|
||||||
|
['t', 'rider-card'],
|
||||||
|
['alt', 'SkateSpots Rider Card'],
|
||||||
|
['theme', c.theme],
|
||||||
|
['v', '1'],
|
||||||
|
];
|
||||||
|
if (c.style.trim()) tags.push(['style', c.style.trim()]);
|
||||||
|
for (const s of c.skills) tags.push(['skill', s]);
|
||||||
|
if (c.sponsors.trim()) tags.push(['sponsor', c.sponsors.trim()]);
|
||||||
|
if (c.ridesFor.trim()) tags.push(['rides-for', c.ridesFor.trim()]);
|
||||||
|
if (c.showSpots) tags.push(['show', 'spots']);
|
||||||
|
if (c.showZaps) tags.push(['show', 'zaps']);
|
||||||
|
if (c.showEvents) tags.push(['show', 'events']);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** naddr for an addressable card event. */
|
||||||
|
export function cardNaddr(kind: number, pubkey: string, d: string): string {
|
||||||
|
return nip19.naddrEncode({ kind, pubkey, identifier: d });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HashRouter URL that opens the verify page for a card. */
|
||||||
|
export function cardVerifyUrl(kind: number, pubkey: string, d: string): string {
|
||||||
|
return `${CARD_ORIGIN}/#/verify/${cardNaddr(kind, pubkey, d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HashRouter URL for a public card page. */
|
||||||
|
export function cardPublicUrl(pubkey: string): string {
|
||||||
|
return `${CARD_ORIGIN}/#/card/${nip19.npubEncode(pubkey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── View presets (shared by the editor + the public card) ────────────────────
|
||||||
|
export const THEMES = [
|
||||||
|
{ id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' },
|
||||||
|
{ id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' },
|
||||||
|
{ id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' },
|
||||||
|
{ id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' },
|
||||||
|
{ id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' },
|
||||||
|
{ id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' },
|
||||||
|
] as const;
|
||||||
|
export type ThemeId = typeof THEMES[number]['id'];
|
||||||
|
|
||||||
|
export const SKILL_OPTIONS = [
|
||||||
|
{ id: 'kickflip', label: 'Kickflip', icon: '🛹' },
|
||||||
|
{ id: 'heelflip', label: 'Heelflip', icon: '👟' },
|
||||||
|
{ id: 'switch', label: 'Switch', icon: '🔄' },
|
||||||
|
{ id: 'nollie', label: 'Nollie', icon: '⬆️' },
|
||||||
|
{ id: 'fakie', label: 'Fakie', icon: '↩️' },
|
||||||
|
{ id: 'grind', label: 'Grinds', icon: '⚡' },
|
||||||
|
{ id: 'slide', label: 'Slides', icon: '➡️' },
|
||||||
|
{ id: 'manual', label: 'Manuals', icon: '🎯' },
|
||||||
|
{ id: 'vert', label: 'Vert', icon: '🏄' },
|
||||||
|
{ id: 'bowl', label: 'Bowl', icon: '🥣' },
|
||||||
|
{ id: 'street', label: 'Street', icon: '🏙️' },
|
||||||
|
{ id: 'park', label: 'Park', icon: '🌿' },
|
||||||
|
{ id: 'film', label: 'Filmer', icon: '🎥' },
|
||||||
|
{ id: 'diy', label: 'DIY Builder', icon: '🔨' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const;
|
||||||
|
|
||||||
|
export function shortNpub(pubkey: string) {
|
||||||
|
if (!pubkey) return '—';
|
||||||
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullNpub(pubkey: string) {
|
||||||
|
if (!pubkey) return '';
|
||||||
|
return nip19.npubEncode(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberSince(createdAt: number) {
|
||||||
|
return new Date(createdAt * 1000).getFullYear().toString();
|
||||||
|
}
|
||||||
128
src/pages/PublicCardPage.tsx
Normal file
128
src/pages/PublicCardPage.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* PublicCardPage — /card/:npub
|
||||||
|
*
|
||||||
|
* A read-only view of a skater's Rider Card, shareable and scannable. The QR on
|
||||||
|
* the card opens /verify so anyone can confirm the card is genuine.
|
||||||
|
*/
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useRiderStats } from '@/hooks/useRiderStats';
|
||||||
|
import { useRiderCard, useRiderBadges } from '@/hooks/useRiderCard';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||||
|
import { RIDER_CARD_KIND, RIDER_CARD_D, DEFAULT_RIDER_CARD, cardVerifyUrl, memberSince } from '@/lib/riderCard';
|
||||||
|
|
||||||
|
function decodePubkey(value: string): string | null {
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase();
|
||||||
|
try {
|
||||||
|
const d = nip19.decode(value);
|
||||||
|
return d.type === 'npub' ? d.data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicCardPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { npub = '' } = useParams<{ npub: string }>();
|
||||||
|
const pubkey = decodePubkey(npub);
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
const author = useAuthor(pubkey ?? '');
|
||||||
|
const metadata = author.data?.metadata;
|
||||||
|
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : '');
|
||||||
|
const { data: stats } = useRiderStats(pubkey ?? undefined);
|
||||||
|
const { data: savedCard } = useRiderCard(pubkey ?? undefined);
|
||||||
|
const { data: badges = [] } = useRiderBadges(pubkey ?? undefined);
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: `${displayName || 'Rider'} card — SkateSpots`,
|
||||||
|
description: `A SkateSpots Rider Card${displayName ? ` for ${displayName}` : ''}.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-20 text-center">
|
||||||
|
<p className="text-gray-400">{t('cardUi.public.invalid')}</p>
|
||||||
|
<Button asChild variant="outline" className="mt-6"><Link to="/card">{t('cardUi.public.createYourOwn')}</Link></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = savedCard?.config ?? DEFAULT_RIDER_CARD;
|
||||||
|
const receivedBadges: BadgeAward[] = badges.map(b => ({
|
||||||
|
skill: b.skill,
|
||||||
|
witnessName: genUserName(b.witnessPubkey),
|
||||||
|
witnessPubkey: b.witnessPubkey,
|
||||||
|
spotName: b.spotName,
|
||||||
|
timestamp: b.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cardData: CardData = {
|
||||||
|
type: 'rider',
|
||||||
|
theme: config.theme as CardData['theme'],
|
||||||
|
holographic: true,
|
||||||
|
name: displayName,
|
||||||
|
nip05: metadata?.nip05,
|
||||||
|
avatar: metadata?.picture,
|
||||||
|
pubkey,
|
||||||
|
npub: nip19.npubEncode(pubkey),
|
||||||
|
style: config.style,
|
||||||
|
skills: config.skills,
|
||||||
|
sponsors: config.sponsors,
|
||||||
|
ridesFor: config.ridesFor,
|
||||||
|
showSpots: config.showSpots,
|
||||||
|
showZaps: config.showZaps,
|
||||||
|
showEvents: config.showEvents,
|
||||||
|
spotsAdded: stats?.spotsAdded ?? 0,
|
||||||
|
zapsReceived: stats?.zapsReceived ?? 0,
|
||||||
|
eventsCreated: stats?.eventsCreated ?? 0,
|
||||||
|
memberYear: metadata?.created_at ? memberSince(metadata.created_at as number) : new Date().getFullYear().toString(),
|
||||||
|
receivedBadges,
|
||||||
|
verifyUrl: cardVerifyUrl(RIDER_CARD_KIND, pubkey, RIDER_CARD_D),
|
||||||
|
issuerName: '',
|
||||||
|
recipientName: '',
|
||||||
|
accessLevel: '',
|
||||||
|
validThru: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMe = user?.pubkey === pubkey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 pb-16">
|
||||||
|
<div className="pt-8 pb-6">
|
||||||
|
<Link to={isMe ? '/card' : '/'} className="inline-flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 uppercase tracking-widest">
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" /> {isMe ? t('cardUi.public.backToEditor') : t('cardUi.public.backHome')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-full rounded-2xl flex items-center justify-center py-8 relative overflow-hidden"
|
||||||
|
style={{ background: 'radial-gradient(ellipse at 50% 60%, rgba(255,0,64,0.08) 0%, rgba(0,0,0,0) 70%), #0a0a0a', border: '1px solid rgba(255,255,255,0.06)' }}
|
||||||
|
>
|
||||||
|
<RiderCardFront d={cardData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!savedCard?.config && (
|
||||||
|
<p className="text-center text-xs text-gray-600 mt-4">{t('cardUi.public.notCustomised')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
<Button asChild className="h-12 bg-gradient-to-r from-[#ff0040] to-[#ff6600] font-black uppercase gap-2">
|
||||||
|
<Link to={isMe ? '/card' : '/card'}>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{isMe ? t('cardUi.public.editYourCard') : t('cardUi.public.createYourOwn')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-[11px] text-gray-600 text-center">{t('cardUi.public.verifyNote')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,13 +13,15 @@
|
||||||
* Print-ready: credit-card size (85.6×54mm), @media print isolates card
|
* Print-ready: credit-card size (85.6×54mm), @media print isolates card
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
|
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
|
||||||
// access at module load time (crashes the preview environment)
|
// access at module load time (crashes the preview environment)
|
||||||
|
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||||
|
import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
@ -31,6 +33,8 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useRiderStats } from '@/hooks/useRiderStats';
|
import { useRiderStats } from '@/hooks/useRiderStats';
|
||||||
|
import { useRiderCard } from '@/hooks/useRiderCard';
|
||||||
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useNostr } from '@/hooks/useNostr';
|
import { useNostr } from '@/hooks/useNostr';
|
||||||
|
|
@ -38,8 +42,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { RIDER_CARD_KIND, RIDER_CARD_D, PARK_ACCESS_CARD_KIND, SKILL_BADGE_KIND, cardVerifyUrl, riderCardTags, type RiderCardConfig } from '@/lib/riderCard';
|
||||||
|
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, Shield, ChevronUp, ChevronDown, User, Building2 } from 'lucide-react';
|
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2 } from 'lucide-react';
|
||||||
// ─── Card canvas renderer ─────────────────────────────────────────────────────
|
// ─── Card canvas renderer ─────────────────────────────────────────────────────
|
||||||
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
||||||
// no CSS capture, no scaling issues, no background-stripping.
|
// no CSS capture, no scaling issues, no background-stripping.
|
||||||
|
|
@ -68,6 +74,8 @@ interface CanvasCardData {
|
||||||
eventsCreated: number;
|
eventsCreated: number;
|
||||||
memberYear: string;
|
memberYear: string;
|
||||||
receivedBadges: BadgeAward[];
|
receivedBadges: BadgeAward[];
|
||||||
|
/** When set, the printed QR opens the verify page for this card. */
|
||||||
|
verifyUrl?: string;
|
||||||
// Park
|
// Park
|
||||||
issuerName: string;
|
issuerName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
|
|
@ -185,7 +193,8 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||||
ctx.fillRect(0, 0, CARD_W, 10);
|
ctx.fillRect(0, 0, CARD_W, 10);
|
||||||
|
|
||||||
// ─── 4. QR code (top-right, generated first — data: URI safe) ────────────
|
// ─── 4. QR code (top-right, generated first — data: URI safe) ────────────
|
||||||
const qrUrl = `https://skate-spots.org/${data.npub}`;
|
// The QR opens the card's verify page when published; otherwise the profile.
|
||||||
|
const qrUrl = data.verifyUrl || `https://skate-spots.org/${data.npub}`;
|
||||||
const qrSize = 168;
|
const qrSize = 168;
|
||||||
const qrX = CARD_W - 32 - qrSize;
|
const qrX = CARD_W - 32 - qrSize;
|
||||||
const qrY = 28;
|
const qrY = 28;
|
||||||
|
|
@ -501,102 +510,6 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
||||||
return canvas.toDataURL('image/png');
|
return canvas.toDataURL('image/png');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Nostr kinds ──────────────────────────────────────────────────────────────
|
|
||||||
export const PARK_ACCESS_CARD_KIND = 35679; // Park issues to rider
|
|
||||||
export const SKILL_BADGE_KIND = 1293; // Witness awards skill badge to rider
|
|
||||||
|
|
||||||
// ─── Themes ───────────────────────────────────────────────────────────────────
|
|
||||||
const THEMES = [
|
|
||||||
{ id: 'thug', label: 'THUG', emoji: '🔴', desc: 'Black + Neon Red' },
|
|
||||||
{ id: 'lime', label: 'LIME', emoji: '🟢', desc: 'Black + Neon Lime' },
|
|
||||||
{ id: 'chrome', label: 'CHROME', emoji: '⚪', desc: 'Steel & Silver' },
|
|
||||||
{ id: 'wood', label: 'WOOD', emoji: '🟤', desc: 'Woodgrain Classic' },
|
|
||||||
{ id: 'midnight', label: 'MIDNIGHT', emoji: '🔵', desc: 'Deep Blue' },
|
|
||||||
{ id: 'sunset', label: 'SUNSET', emoji: '🟠', desc: 'Orange & Pink' },
|
|
||||||
] as const;
|
|
||||||
type ThemeId = typeof THEMES[number]['id'];
|
|
||||||
|
|
||||||
// ─── Skill badges ─────────────────────────────────────────────────────────────
|
|
||||||
const SKILL_OPTIONS = [
|
|
||||||
{ id: 'kickflip', label: 'Kickflip', icon: '🛹' },
|
|
||||||
{ id: 'heelflip', label: 'Heelflip', icon: '👟' },
|
|
||||||
{ id: 'switch', label: 'Switch', icon: '🔄' },
|
|
||||||
{ id: 'nollie', label: 'Nollie', icon: '⬆️' },
|
|
||||||
{ id: 'fakie', label: 'Fakie', icon: '↩️' },
|
|
||||||
{ id: 'grind', label: 'Grinds', icon: '⚡' },
|
|
||||||
{ id: 'slide', label: 'Slides', icon: '➡️' },
|
|
||||||
{ id: 'manual', label: 'Manuals', icon: '🎯' },
|
|
||||||
{ id: 'vert', label: 'Vert', icon: '🏄' },
|
|
||||||
{ id: 'bowl', label: 'Bowl', icon: '🥣' },
|
|
||||||
{ id: 'street', label: 'Street', icon: '🏙️' },
|
|
||||||
{ id: 'park', label: 'Park', icon: '🌿' },
|
|
||||||
{ id: 'film', label: 'Filmer', icon: '🎥' },
|
|
||||||
{ id: 'diy', label: 'DIY Builder', icon: '🔨' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const STYLES = ['Street', 'Park', 'Vert', 'Bowl', 'DIY', 'All-terrain'] as const;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
function shortNpub(pubkey: string) {
|
|
||||||
if (!pubkey) return '—';
|
|
||||||
const npub = nip19.npubEncode(pubkey);
|
|
||||||
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fullNpub(pubkey: string) {
|
|
||||||
if (!pubkey) return '';
|
|
||||||
return nip19.npubEncode(pubkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberSince(createdAt: number) {
|
|
||||||
return new Date(createdAt * 1000).getFullYear().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verification URL — scanning this on a phone opens the profile on skate-spots.org
|
|
||||||
function _verifyUrl(pubkey: string) {
|
|
||||||
if (!pubkey) return 'https://skate-spots.org';
|
|
||||||
return `https://skate-spots.org/${nip19.npubEncode(pubkey)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Badge award event type ───────────────────────────────────────────────────
|
|
||||||
interface BadgeAward {
|
|
||||||
witnessName: string;
|
|
||||||
witnessPubkey: string;
|
|
||||||
skill: string;
|
|
||||||
spotName?: string;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Card data shape ──────────────────────────────────────────────────────────
|
|
||||||
interface CardData {
|
|
||||||
type: 'rider' | 'park';
|
|
||||||
theme: ThemeId;
|
|
||||||
holographic: boolean;
|
|
||||||
// Rider
|
|
||||||
name: string;
|
|
||||||
nip05?: string;
|
|
||||||
avatar?: string;
|
|
||||||
pubkey: string;
|
|
||||||
npub: string;
|
|
||||||
style: string;
|
|
||||||
skills: string[];
|
|
||||||
sponsors: string;
|
|
||||||
ridesFor: string;
|
|
||||||
showSpots: boolean;
|
|
||||||
showZaps: boolean;
|
|
||||||
showEvents: boolean;
|
|
||||||
spotsAdded: number;
|
|
||||||
zapsReceived: number;
|
|
||||||
eventsCreated: number;
|
|
||||||
memberYear: string;
|
|
||||||
receivedBadges: BadgeAward[];
|
|
||||||
// Park
|
|
||||||
issuerName: string;
|
|
||||||
recipientName: string;
|
|
||||||
accessLevel: string;
|
|
||||||
validThru: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
|
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
|
||||||
function useReceivedBadges(pubkey: string) {
|
function useReceivedBadges(pubkey: string) {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
|
|
@ -624,237 +537,6 @@ function useWitnessName(pubkey: string) {
|
||||||
return author.data?.metadata?.name ?? genUserName(pubkey);
|
return author.data?.metadata?.name ?? genUserName(pubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Card visual: Rider front ─────────────────────────────────────────────────
|
|
||||||
// Layout is STRICTLY pixel-fixed to match the 340×214px card container.
|
|
||||||
// No flex-1 / flex-grow — every row has a fixed pixel height.
|
|
||||||
function RiderFront({ d }: { d: CardData }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const witnessedBadges = d.receivedBadges.slice(0, 2);
|
|
||||||
|
|
||||||
return (
|
|
||||||
// absolute inset — fills the card exactly
|
|
||||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
|
||||||
|
|
||||||
{/* ── Row 1: header (26px) ── */}
|
|
||||||
<div className="flex items-start justify-between" style={{ height: '26px' }}>
|
|
||||||
<div>
|
|
||||||
<div className="text-[8px] font-black uppercase tracking-[0.18em] card-accent leading-none">
|
|
||||||
🛹 SkateSpots
|
|
||||||
</div>
|
|
||||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewLicense')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-[5px] card-muted uppercase tracking-wide leading-none">npub</div>
|
|
||||||
<div className="text-[6px] font-bold card-accent font-mono tracking-tight leading-none mt-[1px]">
|
|
||||||
{shortNpub(d.pubkey)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[5px] card-muted mt-[1px]">{t('labUi.rider.previewSince', { year: d.memberYear })}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Row 2: main body (118px) ── */}
|
|
||||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="flex-shrink-0 flex flex-col gap-[4px]">
|
|
||||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
|
||||||
{d.avatar ? (
|
|
||||||
<img src={d.avatar} alt={d.name} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Stats under avatar */}
|
|
||||||
<div className="space-y-[2px]">
|
|
||||||
{d.showSpots && (
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<span className="text-[5px] card-muted">{t('labUi.pyramid.spots')}</span>
|
|
||||||
<span className="text-[7px] font-black card-accent leading-none">{d.spotsAdded}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{d.showZaps && (
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<span className="text-[5px] card-muted">⚡</span>
|
|
||||||
<span className="text-[7px] font-black card-accent2 leading-none">{d.zapsReceived}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{d.showEvents && (
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<span className="text-[5px] card-muted">{t('labUi.rider.previewEvts')}</span>
|
|
||||||
<span className="text-[7px] font-black leading-none" style={{ color: '#ff6600' }}>{d.eventsCreated}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Identity block */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
|
||||||
<div className="text-[14px] font-black uppercase truncate leading-tight">{d.name}</div>
|
|
||||||
{d.nip05 && (
|
|
||||||
<div className="text-[6.5px] card-accent truncate leading-none">@{d.nip05.split('@')[0]}</div>
|
|
||||||
)}
|
|
||||||
{d.style && (
|
|
||||||
<span className="card-bg-chip text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] card-accent w-fit">
|
|
||||||
{d.style}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{d.ridesFor && (
|
|
||||||
<div className="text-[5.5px] card-muted truncate leading-none">🏪 {d.ridesFor}</div>
|
|
||||||
)}
|
|
||||||
{d.sponsors && (
|
|
||||||
<div className="text-[5.5px] card-muted truncate leading-none">🤝 {d.sponsors}</div>
|
|
||||||
)}
|
|
||||||
{/* Skills */}
|
|
||||||
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
|
||||||
{d.skills.slice(0, 4).map(sk => {
|
|
||||||
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
|
||||||
return s ? (
|
|
||||||
<span key={sk} className="card-bg-chip text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none">
|
|
||||||
{s.icon} {s.label}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
{witnessedBadges.map((b, i) => {
|
|
||||||
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
|
||||||
return s ? (
|
|
||||||
<span
|
|
||||||
key={`w${i}`}
|
|
||||||
className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none"
|
|
||||||
style={{ background: 'rgba(255,215,0,0.2)', border: '1px solid rgba(255,215,0,0.5)', color: '#ffd700' }}
|
|
||||||
>
|
|
||||||
⭐ {s.label}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR placeholder — real QR renders in exported PNG */}
|
|
||||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
|
||||||
<div className="rounded-[3px]" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
|
|
||||||
<div style={{ width: '38px', height: '38px', display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '1px' }}>
|
|
||||||
{Array.from({ length: 25 }).map((_, i) => (
|
|
||||||
<div key={i} style={{ background: [0,1,2,5,6,7,10,14,17,18,19,22,23,24].includes(i) ? '#000' : '#fff' }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">{t('labUi.rider.previewScan')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Bottom bar: npub + website (absolute at bottom) ── */}
|
|
||||||
<div
|
|
||||||
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
|
||||||
style={{ bottom: '6px' }}
|
|
||||||
>
|
|
||||||
<span className="card-muted text-[5px] uppercase tracking-widest">
|
|
||||||
skate-spots.org · Powered by Nostr
|
|
||||||
</span>
|
|
||||||
<span className="card-muted text-[5px] font-mono" style={{ maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{shortNpub(d.pubkey)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Card visual: Park Access front ──────────────────────────────────────────
|
|
||||||
function ParkFront({ d }: { d: CardData }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0" style={{ padding: '10px 10px 20px 10px' }}>
|
|
||||||
|
|
||||||
{/* Row 1: header */}
|
|
||||||
<div className="flex items-center justify-between" style={{ height: '26px' }}>
|
|
||||||
<div>
|
|
||||||
<div className="text-[8px] font-black uppercase tracking-[0.15em] card-accent leading-none">
|
|
||||||
🏟️ {d.issuerName || t('labUi.rider.previewSkatepark')}
|
|
||||||
</div>
|
|
||||||
<div className="text-[6px] card-muted uppercase tracking-widest mt-[2px]">{t('labUi.rider.previewAccessPass')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="card-bg-chip px-[6px] py-[2px] rounded-[4px]">
|
|
||||||
<span className="text-[7px] font-black uppercase card-accent">{d.accessLevel || 'Full Access'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: main body */}
|
|
||||||
<div className="flex items-stretch gap-[8px]" style={{ height: '118px', marginTop: '6px' }}>
|
|
||||||
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="w-[52px] h-[52px] rounded-[6px] overflow-hidden border-[1.5px] border-white/20">
|
|
||||||
{d.avatar ? (
|
|
||||||
<img src={d.avatar} alt={d.recipientName} className="w-full h-full object-cover" crossOrigin="anonymous" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center card-bg-chip text-[18px]">🛹</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Identity */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col justify-start gap-[3px]">
|
|
||||||
<div className="text-[13px] font-black uppercase truncate leading-tight">{d.recipientName || t('labUi.rider.previewMemberName')}</div>
|
|
||||||
<div className="text-[5.5px] card-muted font-mono leading-none truncate">{shortNpub(d.pubkey)}</div>
|
|
||||||
<div className="text-[5.5px] card-muted leading-none">{t('labUi.rider.previewIssuedBy')}</div>
|
|
||||||
{d.validThru && (
|
|
||||||
<div className="flex items-center gap-1 mt-[3px]">
|
|
||||||
<span className="text-[5.5px] card-muted uppercase">{t('labUi.rider.previewValidThru')}</span>
|
|
||||||
<span className="text-[8px] font-black card-accent leading-none">{d.validThru}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* QR placeholder */}
|
|
||||||
<div className="flex-shrink-0 flex flex-col items-end justify-end">
|
|
||||||
<div className="rounded-[3px]" style={{ background: '#fff', padding: '3px', width: '44px', height: '44px' }}>
|
|
||||||
<div style={{ width: '38px', height: '38px', display: 'grid', gridTemplateColumns: 'repeat(5,1fr)', gap: '1px' }}>
|
|
||||||
{Array.from({ length: 25 }).map((_, i) => (
|
|
||||||
<div key={i} style={{ background: [0,1,2,5,6,7,10,14,17,18,19,22,23,24].includes(i) ? '#000' : '#fff' }} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[4.5px] card-muted uppercase tracking-wide mt-[2px] text-right">{t('labUi.rider.previewScan')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom bar */}
|
|
||||||
<div
|
|
||||||
className="absolute left-0 right-0 flex items-center justify-between px-[10px]"
|
|
||||||
style={{ bottom: '6px' }}
|
|
||||||
>
|
|
||||||
<span className="card-muted text-[5px] uppercase tracking-widest">
|
|
||||||
skate-spots.org · Powered by Nostr
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Shield className="card-muted" style={{ width: '8px', height: '8px', opacity: 0.5 }} />
|
|
||||||
<span className="card-muted text-[5px] uppercase tracking-widest">{t('labUi.rider.previewNostrVerified')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main card wrapper ────────────────────────────────────────────────────────
|
|
||||||
function RiderCardFront({ d, printRef }: { d: CardData; printRef?: React.RefObject<HTMLDivElement> }) {
|
|
||||||
const themeClass = `card-theme-${d.theme}`;
|
|
||||||
const holoClass = d.holographic ? 'rider-card-holo' : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={printRef}
|
|
||||||
className={cn('rider-card', themeClass, holoClass, 'select-none')}
|
|
||||||
style={{ fontFamily: "'Inter Variable', 'Inter', system-ui, sans-serif" }}
|
|
||||||
>
|
|
||||||
{/* Top color stripe */}
|
|
||||||
<div className="card-stripe absolute top-0 left-0 right-0 h-[4px]" />
|
|
||||||
|
|
||||||
{d.type === 'rider' && <RiderFront d={d} />}
|
|
||||||
{d.type === 'park' && <ParkFront d={d} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Received badges panel ────────────────────────────────────────────────────
|
// ─── Received badges panel ────────────────────────────────────────────────────
|
||||||
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -941,6 +623,8 @@ export default function RiderCardPage() {
|
||||||
const author = useAuthor(user?.pubkey ?? '');
|
const author = useAuthor(user?.pubkey ?? '');
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const { data: stats } = useRiderStats(user?.pubkey);
|
const { data: stats } = useRiderStats(user?.pubkey);
|
||||||
|
const { data: savedCard } = useRiderCard(user?.pubkey);
|
||||||
|
const { data: allSpots } = useSkateSpots(200);
|
||||||
const { mutate: publishEvent } = useNostrPublish();
|
const { mutate: publishEvent } = useNostrPublish();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -988,6 +672,32 @@ export default function RiderCardPage() {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [rendering, setRendering] = useState(false);
|
const [rendering, setRendering] = useState(false);
|
||||||
|
|
||||||
|
// Venue binding: a park pass can name one of the issuer's OWN spots so a
|
||||||
|
// verifier can confirm the issuer really owns the venue (see /verify).
|
||||||
|
const [venueId, setVenueId] = useState('');
|
||||||
|
const [savedParkD, setSavedParkD] = useState('');
|
||||||
|
const myVenues = useMemo(
|
||||||
|
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
|
||||||
|
[allSpots, user?.pubkey],
|
||||||
|
);
|
||||||
|
const venueSpot = myVenues.find((s) => s.id === venueId);
|
||||||
|
|
||||||
|
// Hydrate the editor from the saved Rider Card the first time it loads.
|
||||||
|
const hydratedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydratedRef.current || !savedCard?.config) return;
|
||||||
|
hydratedRef.current = true;
|
||||||
|
const c = savedCard.config;
|
||||||
|
setTheme(c.theme as ThemeId);
|
||||||
|
setStyle(c.style);
|
||||||
|
setSkills(c.skills);
|
||||||
|
setSponsors(c.sponsors);
|
||||||
|
setRidesFor(c.ridesFor);
|
||||||
|
setShowSpots(c.showSpots);
|
||||||
|
setShowZaps(c.showZaps);
|
||||||
|
setShowEvents(c.showEvents);
|
||||||
|
}, [savedCard]);
|
||||||
|
|
||||||
const recipientPubkey = (() => {
|
const recipientPubkey = (() => {
|
||||||
if (!recipientNpub.trim()) return user?.pubkey ?? '';
|
if (!recipientNpub.trim()) return user?.pubkey ?? '';
|
||||||
try {
|
try {
|
||||||
|
|
@ -1014,12 +724,15 @@ export default function RiderCardPage() {
|
||||||
? memberSince(metadata.created_at as number)
|
? memberSince(metadata.created_at as number)
|
||||||
: new Date().getFullYear().toString(),
|
: new Date().getFullYear().toString(),
|
||||||
receivedBadges,
|
receivedBadges,
|
||||||
|
verifyUrl: cardType === 'rider'
|
||||||
|
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined)
|
||||||
|
: (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined),
|
||||||
issuerName, recipientName, accessLevel, validThru,
|
issuerName, recipientName, accessLevel, validThru,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
cardType, theme, holographic, displayName, recipientName, metadata,
|
cardType, theme, holographic, displayName, recipientName, metadata,
|
||||||
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
||||||
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru,
|
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1042,6 +755,18 @@ export default function RiderCardPage() {
|
||||||
finally { setRendering(false); }
|
finally { setRendering(false); }
|
||||||
}, [cardData]);
|
}, [cardData]);
|
||||||
|
|
||||||
|
const handleSaveCard = useCallback(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents };
|
||||||
|
publishEvent(
|
||||||
|
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast({ title: t('cardUi.saved'), description: t('cardUi.savedDesc') }),
|
||||||
|
onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [user, theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, publishEvent, toast, t]);
|
||||||
|
|
||||||
const handlePrint = useCallback(async () => {
|
const handlePrint = useCallback(async () => {
|
||||||
const dataUrl = await renderCardToImage();
|
const dataUrl = await renderCardToImage();
|
||||||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||||||
|
|
@ -1086,17 +811,20 @@ export default function RiderCardPage() {
|
||||||
|
|
||||||
const handlePublishParkCard = useCallback(() => {
|
const handlePublishParkCard = useCallback(() => {
|
||||||
if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; }
|
if (!user || !recipientPubkey) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; }
|
||||||
|
const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`;
|
||||||
const tags: string[][] = [
|
const tags: string[][] = [
|
||||||
['d', `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`],
|
['d', d],
|
||||||
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
|
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
|
||||||
['issuer', issuerName], ['access-level', accessLevel], ['theme', theme],
|
['issuer', issuerName], ['access-level', accessLevel], ['theme', theme],
|
||||||
];
|
];
|
||||||
if (validThru) tags.push(['valid-thru', validThru]);
|
if (validThru) tags.push(['valid-thru', validThru]);
|
||||||
|
// Bind the pass to the venue the issuer owns, so /verify can confirm it.
|
||||||
|
if (venueSpot) tags.push(['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`]);
|
||||||
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
||||||
onSuccess: () => toast({ title: t('labUi.rider.parkPublished') }),
|
onSuccess: () => { setSavedParkD(d); toast({ title: t('labUi.rider.parkPublished') }); },
|
||||||
onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }),
|
onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }),
|
||||||
});
|
});
|
||||||
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, publishEvent, toast, t]);
|
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]);
|
||||||
|
|
||||||
const toggleSkill = (id: string) =>
|
const toggleSkill = (id: string) =>
|
||||||
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||||||
|
|
@ -1222,6 +950,17 @@ export default function RiderCardPage() {
|
||||||
{copied ? t('common.copied') : t('labUi.rider.copyId')}
|
{copied ? t('common.copied') : t('labUi.rider.copyId')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Save the card to Nostr so it persists & verifies */}
|
||||||
|
{cardType === 'rider' && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveCard}
|
||||||
|
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{t('cardUi.save')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Card type toggle ── */}
|
{/* ── Card type toggle ── */}
|
||||||
|
|
@ -1434,6 +1173,18 @@ export default function RiderCardPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('cardUi.venue')}</Label>
|
||||||
|
<Select value={venueId || '__none'} onValueChange={(v) => setVenueId(v === '__none' ? '' : v)}>
|
||||||
|
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue placeholder={t('cardUi.venueNone')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none">{t('cardUi.venueNone')}</SelectItem>
|
||||||
|
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-1.5">{t('cardUi.venueHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePublishParkCard}
|
onClick={handlePublishParkCard}
|
||||||
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||||||
|
|
|
||||||
198
src/pages/VerifyCardPage.tsx
Normal file
198
src/pages/VerifyCardPage.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/**
|
||||||
|
* VerifyCardPage — /verify/:naddr
|
||||||
|
*
|
||||||
|
* Anyone can open this (scan the QR on a printed card) to check a Park Access
|
||||||
|
* Card or Rider Card: it fetches the event, verifies the signature, and — for a
|
||||||
|
* park pass — confirms the issuer really owns the venue it names.
|
||||||
|
*
|
||||||
|
* It is intentionally honest about what it does and does NOT prove.
|
||||||
|
*/
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { nip19, verifyEvent } from 'nostr-tools';
|
||||||
|
import { useSeoMeta } from '@unhead/react';
|
||||||
|
import { BadgeCheck, ShieldAlert, ShieldX, Loader2, ExternalLink, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useNostr } from '@/hooks/useNostr';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { RIDER_CARD_KIND, PARK_ACCESS_CARD_KIND } from '@/lib/riderCard';
|
||||||
|
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
interface ParsedNaddr {
|
||||||
|
kind: number;
|
||||||
|
pubkey: string;
|
||||||
|
identifier: string;
|
||||||
|
relays: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNaddr(value: string): ParsedNaddr | null {
|
||||||
|
try {
|
||||||
|
const d = nip19.decode(value);
|
||||||
|
if (d.type !== 'naddr') return null;
|
||||||
|
const { kind, pubkey, identifier, relays } = d.data;
|
||||||
|
return { kind, pubkey, identifier, relays: relays ?? [] };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyCardPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { naddr = '' } = useParams<{ naddr: string }>();
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const parsed = parseNaddr(naddr);
|
||||||
|
|
||||||
|
useSeoMeta({ title: 'Verify a card — SkateSpots', description: 'Verify a SkateSpots Rider Card or Park Access Card.' });
|
||||||
|
|
||||||
|
const result = useQuery({
|
||||||
|
queryKey: ['verify-card', naddr],
|
||||||
|
enabled: !!parsed,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
if (!parsed) return { status: 'notfound' as const };
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||||
|
const events = await nostr.query(
|
||||||
|
[{ kinds: [parsed.kind], authors: [parsed.pubkey], '#d': [parsed.identifier], limit: 1 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const event = events[0] as NostrEvent | undefined;
|
||||||
|
if (!event) return { status: 'notfound' as const };
|
||||||
|
if (!verifyEvent(event)) return { status: 'invalid' as const };
|
||||||
|
|
||||||
|
// Park pass — confirm the issuer owns the venue it names, when it names one.
|
||||||
|
let venue: { status: 'none' | 'verified' | 'mismatch'; title?: string; coord?: string } = { status: 'none' };
|
||||||
|
if (event.kind === PARK_ACCESS_CARD_KIND) {
|
||||||
|
const aTag = event.tags.find(([n]) => n === 'a')?.[1];
|
||||||
|
if (aTag) {
|
||||||
|
venue = { status: 'mismatch', coord: aTag };
|
||||||
|
const [k, pk, dTag] = aTag.split(':');
|
||||||
|
if (k === String(SKATE_SPOT_KIND) && pk && dTag) {
|
||||||
|
try {
|
||||||
|
const spots = await nostr.query(
|
||||||
|
[{ kinds: [SKATE_SPOT_KIND], authors: [pk], '#d': [dTag], limit: 1 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
const spot = spots[0] as NostrEvent | undefined;
|
||||||
|
if (spot) {
|
||||||
|
venue = { status: spot.pubkey === event.pubkey ? 'verified' : 'mismatch', title: spot.tags.find(([n]) => n === 'title')?.[1] || spot.tags.find(([n]) => n === 'name')?.[1], coord: aTag };
|
||||||
|
}
|
||||||
|
} catch { /* leave mismatch */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'valid' as const, event, venue };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const author = useAuthor(parsed?.pubkey ?? '');
|
||||||
|
const issuerName = author.data?.metadata?.name ?? (parsed ? genUserName(parsed.pubkey) : '');
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
const valid = data && data.status === 'valid' ? data : undefined;
|
||||||
|
const event = valid?.event;
|
||||||
|
const venue = valid?.venue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-10">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-3xl font-black uppercase tracking-tight">
|
||||||
|
<span className="neon-red">VERIFY</span> <span className="text-white">CARD</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!parsed && (
|
||||||
|
<StatusCard tone="warn" icon={<ShieldAlert className="h-10 w-10" />} title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed && result.isLoading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 text-gray-400">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" /> {t('cardUi.verify.checking')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed && !result.isLoading && data?.status === 'notfound' && (
|
||||||
|
<StatusCard tone="warn" icon={<ShieldAlert className="h-10 w-10" />} title={t('cardUi.verify.notFound')} body={t('cardUi.verify.notFoundDesc')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parsed && data?.status === 'invalid' && (
|
||||||
|
<StatusCard tone="bad" icon={<ShieldX className="h-10 w-10" />} title={t('cardUi.verify.invalid')} body={t('cardUi.verify.invalidDesc')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event && (
|
||||||
|
<>
|
||||||
|
<StatusCard tone="good" icon={<BadgeCheck className="h-10 w-10" />} title={t('cardUi.verify.authentic')} body={t('cardUi.verify.authenticDesc')} />
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-2xl border border-white/10 bg-black/40 divide-y divide-white/5">
|
||||||
|
<Row label={t('cardUi.verify.kind')} value={event.kind === PARK_ACCESS_CARD_KIND ? t('cardUi.verify.kindPark') : t('cardUi.verify.kindRider')} />
|
||||||
|
<Row label={t('cardUi.verify.issuedBy')} value={issuerName} />
|
||||||
|
{event.kind === PARK_ACCESS_CARD_KIND && (
|
||||||
|
<>
|
||||||
|
<Row label={t('cardUi.verify.accessLevel')} value={event.tags.find(([n]) => n === 'access-level')?.[1] ?? '—'} />
|
||||||
|
{event.tags.find(([n]) => n === 'valid-thru')?.[1] && (
|
||||||
|
<Row label={t('cardUi.verify.validThru')} value={event.tags.find(([n]) => n === 'valid-thru')?.[1] ?? ''} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{event.kind === RIDER_CARD_KIND && (
|
||||||
|
<Row label={t('cardUi.verify.selfIssued')} value={t('cardUi.verify.selfIssuedDesc')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Venue binding (park passes) */}
|
||||||
|
{event.kind === PARK_ACCESS_CARD_KIND && venue && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/10 bg-black/40 p-4 flex items-start gap-3">
|
||||||
|
<MapPin className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-bold text-white">{t('cardUi.verify.venue')}</div>
|
||||||
|
{venue.status === 'verified' && (
|
||||||
|
<div className="text-green-400 mt-0.5">✓ {t('cardUi.verify.venueVerified', { name: venue.title ?? '' })}</div>
|
||||||
|
)}
|
||||||
|
{venue.status === 'mismatch' && (
|
||||||
|
<div className="text-amber-400 mt-0.5">{t('cardUi.verify.venueMismatch')}</div>
|
||||||
|
)}
|
||||||
|
{venue.status === 'none' && (
|
||||||
|
<div className="text-gray-400 mt-0.5">{t('cardUi.verify.venueNone')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
{parsed && (
|
||||||
|
<Link to={`/card/${nip19.npubEncode(parsed.pubkey)}`} className="text-center text-sm text-[#ccff00] hover:underline inline-flex items-center justify-center gap-1.5">
|
||||||
|
{t('cardUi.verify.viewCard')} <ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-600 text-center mt-1">{t('cardUi.verify.disclaimer')}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({ tone, icon, title, body }: { tone: 'good' | 'warn' | 'bad'; icon: React.ReactNode; title: string; body: string }) {
|
||||||
|
const ring = tone === 'good' ? 'border-green-500/40 bg-green-500/5 text-green-400'
|
||||||
|
: tone === 'warn' ? 'border-amber-500/40 bg-amber-500/5 text-amber-400'
|
||||||
|
: 'border-red-500/40 bg-red-500/5 text-red-400';
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border ${ring} p-6 text-center`}>
|
||||||
|
<div className="flex justify-center mb-3">{icon}</div>
|
||||||
|
<div className="text-lg font-black uppercase tracking-wide">{title}</div>
|
||||||
|
<p className="text-sm text-gray-300 mt-2">{body}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-widest text-gray-500">{label}</span>
|
||||||
|
<span className="text-sm font-bold text-white text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue