- Duotone: desaturate + theme-accent tint the background photo so any image reads as a branded SkateSpots card (text stays legible). Verified visually. - Revocation: the venue owner can revoke an issued pass from the studio list; revoke re-publishes under the same d with status=revoked (addressable supersede — works on every relay). /verify shows a red 'Pass revoked'. - Organiser check-in: an event's organiser can issue a signed access pass (kind 35679) bound to the event (a=31923:...); /verify confirms the issuer OWNS the event (generalised the venue check to spots OR events) and labels it Organiser. - useIssuedPasses/useRevokePass; i18n EN/ES.
1220 lines
61 KiB
TypeScript
1220 lines
61 KiB
TypeScript
/**
|
||
* RiderCardPage — /card
|
||
*
|
||
* Card types:
|
||
* 1. Rider License — personal card auto-filled from Nostr profile
|
||
* 2. Park Access Card — issued by a park owner to a rider (signed Nostr event)
|
||
*
|
||
* Badge System (kind 1293):
|
||
* - Anyone can award a skill badge to any rider
|
||
* - Badges appear on the rider's card with the witness's name
|
||
*
|
||
* 6 visual themes: THUG · LIME · CHROME · WOOD · MIDNIGHT · SUNSET
|
||
* Print-ready: credit-card size (85.6×54mm), @media print isolates card
|
||
*/
|
||
|
||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { nip19 } from 'nostr-tools';
|
||
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
|
||
// access at module load time (crashes the preview environment)
|
||
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
import { CardPreview, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||
import { drawCardToCanvas, type CanvasCardData } from '@/lib/riderCardCanvas';
|
||
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, type ThemeId } from '@/lib/riderCard';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||
import { Badge } from '@/components/ui/badge';
|
||
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { useRiderStats } from '@/hooks/useRiderStats';
|
||
import { useRiderCard, useRiderRoles, useCardProfile, useIssuedPasses, useRevokePass } from '@/hooks/useRiderCard';
|
||
import { useUserCrews } from '@/hooks/useCrews';
|
||
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||
import { useMyRsvpCoords, useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useNostr } from '@/hooks/useNostr';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { useTranslation } from '@/contexts/language';
|
||
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, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
import { Link } from 'react-router-dom';
|
||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket, MoreHorizontal, Pencil, CheckCircle2, RefreshCw, Upload } from 'lucide-react';
|
||
// ─── Hook: fetch skill badges received by a pubkey ────────────────────────────
|
||
function useReceivedBadges(pubkey: string) {
|
||
const { nostr } = useNostr();
|
||
return useQuery({
|
||
queryKey: ['skill-badges', pubkey],
|
||
queryFn: async (c) => {
|
||
if (!pubkey) return [];
|
||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
|
||
const events = await nostr.query(
|
||
[{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 50 }],
|
||
{ signal },
|
||
);
|
||
return events.filter(e =>
|
||
e.tags.some(([k]) => k === 'p') &&
|
||
e.tags.some(([k]) => k === 'skill')
|
||
);
|
||
},
|
||
enabled: !!pubkey,
|
||
});
|
||
}
|
||
|
||
// ─── Hook: fetch author name for a pubkey ─────────────────────────────────────
|
||
function useWitnessName(pubkey: string) {
|
||
const author = useAuthor(pubkey);
|
||
return author.data?.metadata?.name ?? genUserName(pubkey);
|
||
}
|
||
|
||
// ─── Received badges panel ────────────────────────────────────────────────────
|
||
function ReceivedBadgesPanel({ pubkey }: { pubkey: string }) {
|
||
const { t } = useTranslation();
|
||
const { data: badgeEvents = [] } = useReceivedBadges(pubkey);
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
if (badgeEvents.length === 0) {
|
||
return (
|
||
<div className="p-3 rounded-xl bg-black/40 border border-gray-700 text-center">
|
||
<div className="text-2xl mb-1">🏅</div>
|
||
<p className="text-xs text-gray-500">{t('labUi.rider.noBadges')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const show = expanded ? badgeEvents : badgeEvents.slice(0, 3);
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{show.map((event) => (
|
||
<BadgeEventRow key={event.id} event={event} />
|
||
))}
|
||
{badgeEvents.length > 3 && (
|
||
<button
|
||
onClick={() => setExpanded(p => !p)}
|
||
className="w-full text-xs text-gray-500 hover:text-gray-300 flex items-center justify-center gap-1 py-1"
|
||
>
|
||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||
{expanded ? t('common.showLess') : t('labUi.rider.showMore', { count: badgeEvents.length - 3 })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BadgeEventRow({ event }: { event: NostrEvent }) {
|
||
const { t } = useTranslation();
|
||
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
|
||
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
|
||
const witnessName = useWitnessName(event.pubkey);
|
||
const witnessAuthor = useAuthor(event.pubkey);
|
||
const witnessAvatar = witnessAuthor.data?.metadata?.picture;
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 p-2.5 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
|
||
<div className="flex-shrink-0">
|
||
<Avatar className="h-7 w-7">
|
||
<AvatarImage src={witnessAvatar} />
|
||
<AvatarFallback className="text-[10px] bg-yellow-900">{witnessName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||
</Avatar>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex flex-wrap gap-1 mb-0.5">
|
||
{skills.map(sk => {
|
||
const s = SKILL_OPTIONS.find(o => o.id === sk);
|
||
return s ? (
|
||
<Badge key={sk} variant="secondary" className="text-[9px] px-1.5 py-0 bg-yellow-500/20 text-yellow-300 border-yellow-500/30 h-4">
|
||
{s.icon} {s.label}
|
||
</Badge>
|
||
) : null;
|
||
})}
|
||
</div>
|
||
<p className="text-[10px] text-gray-400">
|
||
{t('labUi.rider.witnessedBy')} <span className="text-yellow-400 font-medium">{witnessName}</span>
|
||
{spot && <> {t('labUi.rider.at')} <span className="text-gray-300">{spot}</span></>}
|
||
</p>
|
||
</div>
|
||
<div className="text-[9px] text-gray-600 flex-shrink-0">
|
||
{new Date(event.created_at * 1000).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||
export default function RiderCardPage() {
|
||
const { t } = useTranslation();
|
||
useSeoMeta({
|
||
title: 'Skater Card — SkateSpots',
|
||
description: 'Create your printable SkateSpots Skater License or Park Access Card.',
|
||
});
|
||
|
||
const { user } = useCurrentUser();
|
||
const cardProfile = useCardProfile(user?.pubkey);
|
||
const metadata = cardProfile.data;
|
||
const { data: stats } = useRiderStats(user?.pubkey);
|
||
const { data: savedCard } = useRiderCard(user?.pubkey);
|
||
const { data: allSpots } = useSkateSpots(200);
|
||
const { memberships } = useUserCrews(user?.pubkey);
|
||
const { data: skaterProfile } = useUserSkaterProfile(user?.pubkey);
|
||
const { data: roles = [] } = useRiderRoles(user?.pubkey);
|
||
const { data: issuedPasses = [] } = useIssuedPasses(user?.pubkey);
|
||
const { revoke: revokePass, isPending: revokingPass } = useRevokePass();
|
||
|
||
const crewCrests = useMemo(
|
||
() => memberships.map(m => ({ name: m.crew.name, image: m.crew.image })),
|
||
[memberships],
|
||
);
|
||
const local = skaterProfile?.location || skaterProfile?.homeSpot || '';
|
||
// Style + level are DERIVED from the Skater profile — no manual typing.
|
||
const derivedStyle = useMemo(
|
||
() => (skaterProfile?.styles ?? []).map(s => SKATER_STYLES[s]?.label ?? s).join(' · '),
|
||
[skaterProfile],
|
||
);
|
||
const derivedLevel = skaterProfile?.level
|
||
? (SKATER_LEVELS[skaterProfile.level]?.label ?? '')
|
||
: (skaterProfile?.customLevel ?? '');
|
||
|
||
// "Finish your card" checklist — a card is only as good as what's on it.
|
||
const cardChecks = [
|
||
{ done: !!metadata?.picture, label: t('cardUi.studio.checkPhoto'), to: '/settings?section=profile' },
|
||
{ done: !!metadata?.name, label: t('cardUi.studio.checkName'), to: '/settings?section=profile' },
|
||
{ done: (skaterProfile?.styles?.length ?? 0) > 0 || !!skaterProfile?.level, label: t('cardUi.studio.checkProfile'), to: '/skaters' },
|
||
{ done: memberships.length > 0, label: t('cardUi.studio.checkCrew'), to: '/crews' },
|
||
];
|
||
const incomplete = cardChecks.some((c) => !c.done);
|
||
const { mutate: publishEvent } = useNostrPublish();
|
||
const { mutateAsync: uploadFile, isPending: uploadingLogo } = useUploadFile();
|
||
const { toast } = useToast();
|
||
|
||
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
|
||
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : '';
|
||
|
||
const { data: receivedBadgeEvents = [] } = useReceivedBadges(user?.pubkey ?? '');
|
||
const receivedBadges: BadgeAward[] = receivedBadgeEvents.flatMap(event => {
|
||
const skills = event.tags.filter(([k]) => k === 'skill').map(([, v]) => v);
|
||
const spot = event.tags.find(([k]) => k === 'spot')?.[1];
|
||
return skills.map(skill => ({
|
||
skill,
|
||
witnessName: genUserName(event.pubkey),
|
||
witnessPubkey: event.pubkey,
|
||
spotName: spot,
|
||
timestamp: event.created_at,
|
||
}));
|
||
});
|
||
|
||
// ── Page tab: 'card' | 'badges' | 'award' ────────────────────────────────
|
||
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award'>('card');
|
||
|
||
// ── Card state ────────────────────────────────────────────────────────────
|
||
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider');
|
||
const [theme, setTheme] = useState<ThemeId>('thug');
|
||
const [holographic, setHolographic] = useState(true);
|
||
|
||
// Rider fields
|
||
const [sponsors, setSponsors] = useState('');
|
||
const [ridesFor, setRidesFor] = useState('');
|
||
const [showSpots, setShowSpots] = useState(true);
|
||
const [showZaps, setShowZaps] = useState(true);
|
||
const [showEvents, setShowEvents] = useState(true);
|
||
const [showCrews, setShowCrews] = useState(true);
|
||
const [showLocal, setShowLocal] = useState(true);
|
||
const [showRoles, setShowRoles] = useState(true);
|
||
|
||
// Customization depth
|
||
const [accent, setAccent] = useState('');
|
||
const [logo, setLogo] = useState('');
|
||
const [back, setBack] = useState(true);
|
||
const [cardSide, setCardSide] = useState<'front' | 'back'>('front');
|
||
|
||
// Park fields
|
||
const [issuerName, setIssuerName] = useState('');
|
||
const [recipientName, setRecipientName] = useState('');
|
||
const [recipientNpub, setRecipientNpub] = useState('');
|
||
const [accessLevel, setAccessLevel] = useState('Full Access');
|
||
const [validThru, setValidThru] = useState('');
|
||
|
||
const [copied, setCopied] = 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 [crewId, setCrewId] = useState('');
|
||
const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew;
|
||
|
||
// Event pass / wristband: events you organize, or that you RSVP'd going to.
|
||
const [eventId, setEventId] = useState('');
|
||
const { data: allEvents } = useSkateEvents(200);
|
||
const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey);
|
||
const myEvents = useMemo(() => {
|
||
const rsvpSet = new Set(rsvpCoords);
|
||
return (allEvents ?? []).filter(ev => ev.pubkey === user?.pubkey || rsvpSet.has(eventCoord(ev)));
|
||
}, [allEvents, user?.pubkey, rsvpCoords]);
|
||
const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0];
|
||
const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined);
|
||
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);
|
||
setSponsors(c.sponsors);
|
||
setRidesFor(c.ridesFor);
|
||
setShowSpots(c.showSpots);
|
||
setShowZaps(c.showZaps);
|
||
setShowEvents(c.showEvents);
|
||
setShowCrews(c.showCrews);
|
||
setShowLocal(c.showLocal);
|
||
setShowRoles(c.showRoles);
|
||
setAccent(c.accent);
|
||
setLogo(c.logo);
|
||
setBack(c.back);
|
||
}, [savedCard]);
|
||
|
||
// Unsaved-changes tracking for the personal card (manual save — an
|
||
// interactive signer must not be prompted on every keystroke).
|
||
const isDirty = useMemo(() => {
|
||
const c = savedCard?.config;
|
||
if (!c) return true;
|
||
return (
|
||
c.theme !== theme || c.sponsors !== sponsors || c.ridesFor !== ridesFor ||
|
||
c.showSpots !== showSpots || c.showZaps !== showZaps || c.showEvents !== showEvents ||
|
||
c.showCrews !== showCrews || c.showLocal !== showLocal || c.showRoles !== showRoles ||
|
||
c.accent !== accent || c.logo !== logo || c.back !== back
|
||
);
|
||
}, [savedCard, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back]);
|
||
|
||
const recipientPubkey = (() => {
|
||
if (!recipientNpub.trim()) return user?.pubkey ?? '';
|
||
try {
|
||
const d = nip19.decode(recipientNpub.trim());
|
||
return d.type === 'npub' ? d.data : (user?.pubkey ?? '');
|
||
} catch { return user?.pubkey ?? ''; }
|
||
})();
|
||
|
||
const cardPubkey = cardType === 'rider'
|
||
? (user?.pubkey ?? '')
|
||
: cardType === 'park'
|
||
? recipientPubkey
|
||
: cardType === 'crew'
|
||
? (activeCrew?.pubkey ?? user?.pubkey ?? '')
|
||
: (activeEvent?.pubkey ?? '');
|
||
const cardData: CardData = useMemo(
|
||
() => ({
|
||
type: cardType, theme, holographic,
|
||
name: cardType === 'rider' ? displayName : recipientName,
|
||
nip05: metadata?.nip05,
|
||
avatar: cardType === 'crew' ? activeCrew?.image : cardType === 'event' ? activeEvent?.image : metadata?.picture,
|
||
pubkey: cardPubkey,
|
||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||
style: derivedStyle,
|
||
level: derivedLevel || undefined,
|
||
skills: [],
|
||
accent: accent || undefined,
|
||
accent2: accent || undefined,
|
||
logo: logo || undefined,
|
||
side: cardSide,
|
||
sponsors, ridesFor,
|
||
showSpots, showZaps, showEvents,
|
||
crews: crewCrests,
|
||
local: local || undefined,
|
||
roles,
|
||
showCrews, showLocal, showRoles,
|
||
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: cardType === 'rider'
|
||
? (user?.pubkey ? cardVerifyUrl(RIDER_CARD_KIND, user.pubkey, RIDER_CARD_D) : undefined)
|
||
: cardType === 'park'
|
||
? (savedParkD && user?.pubkey ? cardVerifyUrl(PARK_ACCESS_CARD_KIND, user.pubkey, savedParkD) : undefined)
|
||
: cardType === 'crew'
|
||
? (activeCrew ? cardVerifyUrl(SKATE_CREW_KIND, activeCrew.pubkey, activeCrew.identifier) : undefined)
|
||
: (activeEvent
|
||
? `${cardVerifyUrl(CALENDAR_EVENT_KIND, activeEvent.pubkey, activeEvent.identifier)}${activeEvent.pubkey !== user?.pubkey && user?.pubkey ? `?h=${nip19.npubEncode(user.pubkey)}` : ''}`
|
||
: undefined),
|
||
crew: cardType === 'crew' && activeCrew ? {
|
||
name: activeCrew.name,
|
||
image: activeCrew.image,
|
||
pubkey: activeCrew.pubkey,
|
||
identifier: activeCrew.identifier,
|
||
location: activeCrew.location,
|
||
founded: activeCrew.founded,
|
||
members: activeCrew.members.length,
|
||
focus: activeCrew.focus,
|
||
vibes: activeCrew.vibes,
|
||
lookingFor: activeCrew.lookingFor,
|
||
spots: activeCrew.spotRefs.length,
|
||
} : undefined,
|
||
event: cardType === 'event' && activeEvent ? {
|
||
title: activeEvent.title,
|
||
start: activeEvent.start,
|
||
end: activeEvent.end,
|
||
location: activeEvent.location,
|
||
image: activeEvent.image,
|
||
pubkey: activeEvent.pubkey,
|
||
identifier: activeEvent.identifier,
|
||
going: eventRsvp.goingCount,
|
||
holder: displayName,
|
||
organiser: activeEvent.pubkey === user?.pubkey,
|
||
} : undefined,
|
||
issuerName, recipientName, accessLevel, validThru,
|
||
}),
|
||
[
|
||
cardType, theme, holographic, displayName, recipientName, metadata,
|
||
cardPubkey, derivedStyle, derivedLevel, sponsors, ridesFor, showSpots, showZaps,
|
||
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru, savedParkD, user?.pubkey,
|
||
crewCrests, local, roles, showCrews, showLocal, showRoles, activeCrew, activeEvent, eventRsvp.goingCount,
|
||
accent, logo, cardSide,
|
||
],
|
||
);
|
||
|
||
const renderCardToImage = useCallback(async (side: 'front' | 'back' = 'front'): Promise<string | null> => {
|
||
setRendering(true);
|
||
try {
|
||
const canvasData: CanvasCardData = {
|
||
type: cardData.type,
|
||
themeId: cardData.theme,
|
||
accentOverride: cardData.accent,
|
||
name: cardData.name,
|
||
nip05: cardData.nip05,
|
||
avatarUrl: cardData.avatar,
|
||
logoUrl: cardData.logo,
|
||
pubkey: cardData.pubkey,
|
||
npub: cardData.npub,
|
||
style: cardData.style,
|
||
level: cardData.level,
|
||
sponsors: cardData.sponsors,
|
||
ridesFor: cardData.ridesFor,
|
||
showSpots: cardData.showSpots,
|
||
showZaps: cardData.showZaps,
|
||
showEvents: cardData.showEvents,
|
||
showCrews: cardData.showCrews ?? true,
|
||
showLocal: cardData.showLocal ?? true,
|
||
showRoles: cardData.showRoles ?? true,
|
||
spotsAdded: cardData.spotsAdded,
|
||
zapsReceived: cardData.zapsReceived,
|
||
eventsCreated: cardData.eventsCreated,
|
||
memberYear: cardData.memberYear,
|
||
receivedBadges: cardData.receivedBadges,
|
||
verifyUrl: cardData.verifyUrl,
|
||
local: cardData.local,
|
||
crews: (cardData.crews ?? []).map(c => c.name),
|
||
roles: cardData.roles,
|
||
crew: cardData.crew,
|
||
event: cardData.event,
|
||
issuerName: cardData.issuerName,
|
||
recipientName: cardData.recipientName,
|
||
accessLevel: cardData.accessLevel,
|
||
validThru: cardData.validThru,
|
||
holographic: cardData.holographic,
|
||
};
|
||
return await drawCardToCanvas(canvasData, side);
|
||
} catch (err) { console.error('Card render failed:', err); return null; }
|
||
finally { setRendering(false); }
|
||
}, [cardData]);
|
||
|
||
const handleSaveCard = useCallback(() => {
|
||
if (!user) return;
|
||
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back };
|
||
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, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, accent, logo, back, publishEvent, toast, t]);
|
||
|
||
const handlePublishEventPass = useCallback(() => {
|
||
if (!user || !activeEvent) return;
|
||
if (activeEvent.pubkey !== user.pubkey) { toast({ title: t('cardUi.event.notOrganiser'), variant: 'destructive' }); return; }
|
||
if (!recipientNpub.trim()) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; }
|
||
const d = `event-pass-${activeEvent.identifier}-${recipientPubkey.slice(0, 8)}-${Date.now()}`;
|
||
const tags: string[][] = [
|
||
['d', d],
|
||
['p', recipientPubkey], ['t', 'event-checkin'], ['alt', 'Event check-in pass'],
|
||
['issuer', activeEvent.title],
|
||
['a', `${CALENDAR_EVENT_KIND}:${activeEvent.pubkey}:${activeEvent.identifier}`],
|
||
];
|
||
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
||
onSuccess: () => toast({ title: t('cardUi.event.passIssued') }),
|
||
onError: (e) => toast({ title: t('cardUi.event.issueFailed'), description: String(e), variant: 'destructive' }),
|
||
});
|
||
}, [user, activeEvent, recipientNpub, recipientPubkey, publishEvent, toast, t]);
|
||
|
||
const handlePrint = useCallback(async () => {
|
||
const front = await renderCardToImage('front');
|
||
if (!front) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||
const backImg = back ? await renderCardToImage('back') : null;
|
||
const w = Math.round(85.6 * 2.835);
|
||
const h = Math.round(54 * 2.835);
|
||
const win = window.open('', '_blank', `width=${w * 2},height=${h * 2}`);
|
||
if (!win) { toast({ title: t('labUi.rider.popupBlocked'), description: t('labUi.rider.popupBlockedDesc'), variant: 'destructive' }); return; }
|
||
const imgs = `<img src="${front}"/>${backImg ? `<img src="${backImg}"/>` : ''}`;
|
||
win.document.write(`<!DOCTYPE html><html><head><title>SkateSpots Card</title><style>@page{size:85.6mm 54mm;margin:0}html,body{margin:0;padding:0;background:#000}img{display:block;width:85.6mm;height:54mm;break-after:page}img:last-child{break-after:auto}</style></head><body>${imgs}<scr${''}ipt>window.onload=function(){window.focus();window.print()}</scr${''}ipt></body></html>`);
|
||
win.document.close();
|
||
}, [renderCardToImage, back, toast, t]);
|
||
|
||
const handleDownload = useCallback(async () => {
|
||
const dataUrl = await renderCardToImage();
|
||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||
const a = document.createElement('a');
|
||
a.href = dataUrl;
|
||
a.download = `skatespot-card-${cardData.npub.slice(0, 16)}.png`;
|
||
a.click();
|
||
}, [renderCardToImage, cardData.npub, toast, t]);
|
||
|
||
const handleLogoUpload = useCallback(async (file: File) => {
|
||
try {
|
||
const tags = await uploadFile(file);
|
||
const url = tags[0]?.[1];
|
||
if (url) setLogo(url);
|
||
toast({ title: t('cardUi.logoUploaded') });
|
||
} catch (e) {
|
||
toast({ title: t('cardUi.logoUploadFailed'), description: String(e), variant: 'destructive' });
|
||
}
|
||
}, [uploadFile, toast, t]);
|
||
|
||
const handleCopyNpub = useCallback(() => {
|
||
navigator.clipboard.writeText(npub);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}, [npub]);
|
||
|
||
const handleShare = useCallback(async () => {
|
||
const dataUrl = await renderCardToImage();
|
||
if (!dataUrl) { toast({ title: t('labUi.rider.couldNotRender'), variant: 'destructive' }); return; }
|
||
const res = await fetch(dataUrl);
|
||
const blob = await res.blob();
|
||
const file = new File([blob], `skatespot-card-${cardData.name.replace(/\s+/g, '-')}.png`, { type: 'image/png' });
|
||
if (navigator.share && navigator.canShare?.({ files: [file] })) {
|
||
try {
|
||
await navigator.share({ title: `${cardData.name}'s SkateSpots Card`, text: `Check out my SkateSpots Skater License 🛹\nskate-spots.org/${cardData.npub}`, files: [file] });
|
||
return;
|
||
} catch { /* fall through */ }
|
||
}
|
||
await navigator.clipboard.writeText(`https://skate-spots.org/${cardData.npub}`);
|
||
toast({ title: t('labUi.rider.linkCopied') });
|
||
}, [renderCardToImage, cardData.name, cardData.npub, toast, t]);
|
||
|
||
const handlePublishParkCard = useCallback(() => {
|
||
if (!user) return;
|
||
if (!recipientPubkey || !recipientNpub.trim()) { toast({ title: t('labUi.rider.enterRiderNpub'), variant: 'destructive' }); return; }
|
||
// ISSUANCE RULE: only the venue's owner key may issue a Park pass, and it
|
||
// must be bound to that venue — otherwise the pass proves nothing.
|
||
if (!venueSpot) { toast({ title: t('cardUi.parkNeedsVenue'), variant: 'destructive' }); return; }
|
||
const d = `park-access-${recipientPubkey.slice(0, 16)}-${Date.now()}`;
|
||
const tags: string[][] = [
|
||
['d', d],
|
||
['p', recipientPubkey], ['t', 'skatepark-access'], ['alt', 'Skatepark Access Card'],
|
||
['issuer', issuerName.trim() || venueSpot.title], ['access-level', accessLevel], ['theme', theme],
|
||
['a', `${SKATE_SPOT_KIND}:${venueSpot.pubkey}:${venueSpot.identifier}`],
|
||
];
|
||
if (validThru) tags.push(['valid-thru', validThru]);
|
||
publishEvent({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }, {
|
||
onSuccess: () => { setSavedParkD(d); toast({ title: t('labUi.rider.parkPublished') }); },
|
||
onError: (e) => toast({ title: t('labUi.rider.publishFailed'), description: String(e), variant: 'destructive' }),
|
||
});
|
||
}, [user, recipientPubkey, recipientNpub, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]);
|
||
|
||
// ── Logged out ────────────────────────────────────────────────────────────
|
||
if (!user) {
|
||
return (
|
||
<>
|
||
<div className="min-h-[70vh] flex flex-col items-center justify-center px-4 text-center">
|
||
{/* Hero */}
|
||
<div className="mb-8 relative">
|
||
<div className="absolute inset-0 blur-3xl opacity-20 bg-gradient-to-br from-[#ff0040] to-[#ccff00] rounded-full" />
|
||
<div className="relative text-7xl mb-4">🛹</div>
|
||
</div>
|
||
<h1 className="text-4xl font-black uppercase mb-2">
|
||
<span className="neon-red">RIDER</span>
|
||
<span className="text-white"> CARD</span>
|
||
</h1>
|
||
<p className="text-gray-400 max-w-sm mb-2">
|
||
{t('labUi.rider.heroDesc')}
|
||
</p>
|
||
<p className="text-gray-600 text-sm mb-8 max-w-xs">
|
||
{t('labUi.rider.heroDesc2')}
|
||
</p>
|
||
<LoginArea className="justify-center" />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="mx-auto max-w-5xl px-4 pb-24">
|
||
|
||
{/* ── Page header ── */}
|
||
<div className="pt-8 pb-6 text-center">
|
||
<h1 className="text-3xl font-black uppercase tracking-tight mb-1">
|
||
<span className="neon-red">RIDER</span>
|
||
<span className="text-white"> CARD</span>
|
||
</h1>
|
||
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
|
||
</div>
|
||
|
||
{/* ── Tabs: My Card / Badges / Award ── */}
|
||
<div className="mx-auto mb-8 flex max-w-2xl overflow-hidden rounded-xl border border-white/10 bg-black/40">
|
||
{([
|
||
{ id: 'card', label: `🪪 ${t('labUi.rider.tabCard')}` },
|
||
{ id: 'badges', label: `🏅 ${t('labUi.rider.tabBadges')}${receivedBadges.length > 0 ? ` (${receivedBadges.length})` : ''}` },
|
||
{ id: 'award', label: `⭐ ${t('labUi.rider.tabAward')}` },
|
||
] as const).map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setPageTab(tab.id)}
|
||
className={cn(
|
||
'flex-1 py-3 text-xs font-black uppercase tracking-widest transition-all',
|
||
pageTab === tab.id ? 'bg-[#ff0040] text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-300'
|
||
)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{pageTab === 'card' && (
|
||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start lg:gap-8">
|
||
|
||
{/* ══ LEFT: sticky preview + actions ══ */}
|
||
<div className="space-y-4 lg:sticky lg:top-20">
|
||
<div
|
||
className="relative flex w-full items-center justify-center overflow-hidden rounded-2xl py-8"
|
||
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)' }}
|
||
>
|
||
<div
|
||
className="absolute inset-x-0 top-1/2 h-32 -translate-y-1/2 opacity-30 blur-3xl"
|
||
style={{ background: `radial-gradient(ellipse, ${theme === 'lime' ? '#ccff00' : theme === 'midnight' ? '#64a0ff' : theme === 'wood' ? '#f5c842' : theme === 'chrome' ? '#888' : theme === 'sunset' ? '#ff6020' : '#ff0040'}, transparent)` }}
|
||
/>
|
||
<CardPreview d={cardData} />
|
||
</div>
|
||
|
||
{/* Flip front/back (personal card) */}
|
||
{cardType === 'rider' && (
|
||
<div className="flex justify-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => setCardSide(s => s === 'front' ? 'back' : 'front')}
|
||
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-300 hover:bg-white/10"
|
||
>
|
||
<RefreshCw className="h-3 w-3" />
|
||
{cardSide === 'front' ? t('cardUi.back.showBack') : t('cardUi.back.showFront')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Saved state */}
|
||
{cardType === 'rider' && (
|
||
<div className="flex items-center justify-center text-[10px] font-black uppercase tracking-widest">
|
||
{isDirty
|
||
? <span className="inline-flex items-center gap-1.5 text-amber-400"><Pencil className="h-3 w-3" /> {t('cardUi.studio.unsaved')}</span>
|
||
: <span className="inline-flex items-center gap-1.5 text-green-400"><CheckCircle2 className="h-3 w-3" /> {t('cardUi.studio.saved')}</span>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Primary action: Share + More */}
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={handleShare}
|
||
disabled={rendering}
|
||
size="lg"
|
||
className="h-12 flex-1 gap-2 rounded-xl bg-gradient-to-r from-[#ff0040] to-[#ff6600] font-black uppercase shadow-xl shadow-[#ff0040]/20 hover:opacity-90"
|
||
>
|
||
{rendering ? <Loader2 className="h-5 w-5 animate-spin" /> : <Share2 className="h-5 w-5" />}
|
||
{rendering ? t('labUi.rider.rendering') : t('labUi.rider.shareCard')}
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="outline" size="lg" className="h-12 w-12 rounded-xl border-white/10 px-0">
|
||
<MoreHorizontal className="h-5 w-5" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-48">
|
||
<DropdownMenuItem onClick={handleDownload} disabled={rendering} className="gap-2">
|
||
<Download className="h-4 w-4" /> {t('labUi.rider.savePng')}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={handlePrint} disabled={rendering} className="gap-2">
|
||
<Printer className="h-4 w-4" /> {t('labUi.rider.print')}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={handleCopyNpub} className="gap-2">
|
||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||
{copied ? t('common.copied') : t('labUi.rider.copyId')}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
{/* Save (personal card only) */}
|
||
{cardType === 'rider' && (
|
||
<Button
|
||
onClick={handleSaveCard}
|
||
disabled={!isDirty}
|
||
className={cn(
|
||
'h-12 w-full gap-2 font-black uppercase',
|
||
isDirty ? 'bg-[#ccff00] text-black hover:bg-[#b8e600]' : 'bg-white/5 text-gray-500',
|
||
)}
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
{isDirty ? t('cardUi.studio.saveChanges') : t('cardUi.studio.allSaved')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ══ RIGHT: inspector ══ */}
|
||
<div className="mt-8 space-y-6 lg:mt-0">
|
||
|
||
{/* Finish-your-card checklist (personal card) */}
|
||
{cardType === 'rider' && incomplete && (
|
||
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/5 p-4">
|
||
<div className="text-xs font-black uppercase tracking-wide text-[#ccff00]">{t('cardUi.studio.finishTitle')}</div>
|
||
<p className="mt-1 text-[11px] text-gray-400">{t('cardUi.studio.finishDesc')}</p>
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{cardChecks.map((c) => (
|
||
c.done ? (
|
||
<span key={c.label} className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] font-bold text-green-400">
|
||
✓ {c.label}
|
||
</span>
|
||
) : (
|
||
<Link key={c.label} to={c.to} className="inline-flex items-center gap-1 rounded-lg border border-[#ccff00]/40 bg-[#ccff00]/10 px-2.5 py-1 text-[11px] font-bold text-[#ccff00] hover:bg-[#ccff00]/20">
|
||
+ {c.label}
|
||
</Link>
|
||
)
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Card type */}
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('labUi.rider.cardType')}</p>
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{([
|
||
{ id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User },
|
||
...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2 }] : []),
|
||
...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users }] : []),
|
||
...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket }] : []),
|
||
]).map(({ id, label, icon: Icon }) => (
|
||
<button
|
||
key={id}
|
||
onClick={() => setCardType(id)}
|
||
className={cn(
|
||
'flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-center transition-all',
|
||
cardType === id
|
||
? 'border-[#ff0040]/60 bg-[#ff0040]/10 text-white'
|
||
: 'border-white/8 bg-black/30 text-gray-500 hover:border-white/20 hover:text-gray-300'
|
||
)}
|
||
>
|
||
<Icon className={cn('h-4 w-4', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} />
|
||
<span className="text-[9px] font-black uppercase leading-tight">{label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
{(myVenues.length === 0 || memberships.length === 0 || myEvents.length === 0) && (
|
||
<p className="mt-2 text-[10px] leading-relaxed text-gray-600">{t('cardUi.studio.moreTypes')}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Theme */}
|
||
<div>
|
||
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-gray-600">
|
||
<Palette className="h-3 w-3" /> {t('labUi.rider.theme')}
|
||
</p>
|
||
<div className="grid grid-cols-6 gap-2">
|
||
{THEMES.map((th) => (
|
||
<button
|
||
key={th.id}
|
||
onClick={() => setTheme(th.id)}
|
||
className={cn(
|
||
'flex aspect-square flex-col items-center justify-center gap-0.5 rounded-xl border-2 transition-all',
|
||
`card-theme-${th.id}`,
|
||
theme === th.id ? 'scale-105 border-white/30 ring-2 ring-white/50 ring-offset-1 ring-offset-black' : 'border-white/10 opacity-60 hover:opacity-90'
|
||
)}
|
||
>
|
||
<span className="text-lg">{th.emoji}</span>
|
||
<span className="text-[7px] font-black uppercase opacity-80">{th.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Holographic */}
|
||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-white/3 px-4 py-3">
|
||
<div>
|
||
<div className="text-sm font-bold">✨ {t('labUi.rider.holoTitle')}</div>
|
||
<div className="text-xs text-gray-500">{t('labUi.rider.holoDesc')}</div>
|
||
</div>
|
||
<Switch checked={holographic} onCheckedChange={setHolographic} />
|
||
</div>
|
||
|
||
{/* Design: custom accent + logo (rider only) */}
|
||
{cardType === 'rider' && (
|
||
<>
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.accent')}</p>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="color"
|
||
value={accent || '#ff0040'}
|
||
onChange={(e) => setAccent(e.target.value)}
|
||
className="h-10 w-14 cursor-pointer rounded-lg border border-white/10 bg-transparent"
|
||
aria-label={t('cardUi.studio.accent')}
|
||
/>
|
||
{accent
|
||
? <button type="button" onClick={() => setAccent('')} className="text-xs text-gray-400 underline">{t('cardUi.studio.accentClear')}</button>
|
||
: <span className="text-[11px] text-gray-600">{t('cardUi.studio.accentHint')}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.logo')}</p>
|
||
<div className="flex items-center gap-3">
|
||
{logo && <img src={logo} alt="" className="h-11 w-11 rounded-lg border border-white/10 object-cover" />}
|
||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs font-bold text-gray-300 hover:bg-white/5">
|
||
{uploadingLogo ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||
{logo ? t('cardUi.studio.logoChange') : t('cardUi.studio.logoAdd')}
|
||
<input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleLogoUpload(f); }} />
|
||
</label>
|
||
{logo && <button type="button" onClick={() => setLogo('')} className="text-xs text-gray-400 underline">{t('cardUi.studio.logoClear')}</button>}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Printable back side */}
|
||
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-white/3 px-4 py-3">
|
||
<div>
|
||
<div className="text-sm font-bold">{t('cardUi.back.title')}</div>
|
||
<div className="text-xs text-gray-500">{t('cardUi.back.desc')}</div>
|
||
</div>
|
||
<Switch checked={back} onCheckedChange={setBack} />
|
||
</div>
|
||
|
||
{/* Show on card — ONE clear list */}
|
||
{cardType === 'rider' && (
|
||
<>
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.studio.showOnCard')}</p>
|
||
<div className="divide-y divide-white/5 rounded-xl border border-white/8">
|
||
{[
|
||
{ icon: '🛹', label: t('labUi.pyramid.spots'), value: String(stats?.spotsAdded ?? 0), state: showSpots, set: setShowSpots },
|
||
{ icon: '⚡', label: t('labUi.rider.zaps'), value: String(stats?.zapsReceived ?? 0), state: showZaps, set: setShowZaps },
|
||
{ icon: '📅', label: t('labUi.pyramid.events'), value: String(stats?.eventsCreated ?? 0), state: showEvents, set: setShowEvents },
|
||
{ icon: '🏕️', label: t('cardUi.facets.crews'), value: String(crewCrests.length), state: showCrews, set: setShowCrews },
|
||
{ icon: '📍', label: t('cardUi.facets.local'), value: local || '—', state: showLocal, set: setShowLocal },
|
||
{ icon: '🎓', label: t('cardUi.facets.roles'), value: String(roles.length), state: showRoles, set: setShowRoles },
|
||
].map((row) => (
|
||
<div key={row.label} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="text-base">{row.icon}</span>
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-bold text-white">{row.label}</div>
|
||
<div className="truncate text-[10px] text-gray-500">{row.value}</div>
|
||
</div>
|
||
</div>
|
||
<Switch checked={row.state} onCheckedChange={row.set} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Details */}
|
||
<div className="space-y-4 border-t border-white/8 pt-4">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('labUi.rider.cardDetails')}</p>
|
||
|
||
<div className="rounded-xl border border-white/8 bg-white/3 p-3">
|
||
<div className="text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.studio.derived')}</div>
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{derivedStyle && <span className="rounded-md bg-white/5 px-2 py-0.5 text-[11px] font-bold text-white">{derivedStyle}</span>}
|
||
{derivedLevel && <span className="rounded-md px-2 py-0.5 text-[11px] font-bold" style={{ background: 'rgba(255,102,0,0.15)', color: '#ff9944' }}>{derivedLevel}</span>}
|
||
{!derivedStyle && !derivedLevel && (
|
||
<span className="text-[11px] text-gray-500">
|
||
{t('cardUi.studio.noSkaterProfile')}{' '}
|
||
<Link to="/skaters" className="text-[#ccff00] underline">{t('cardUi.studio.editSkaterProfile')}</Link>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{!metadata?.picture && (
|
||
<p className="text-[11px] text-amber-400">
|
||
{t('cardUi.studio.noPhoto')}{' '}
|
||
<Link to="/settings?section=profile" className="underline">{t('cardUi.studio.addPhoto')}</Link>
|
||
</p>
|
||
)}
|
||
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.ridesFor')}</Label>
|
||
<Input
|
||
placeholder={t('labUi.rider.ridesForPlaceholder')}
|
||
value={ridesFor}
|
||
onChange={e => setRidesFor(e.target.value)}
|
||
className="h-11 border-white/10 bg-black/50 text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.sponsors')}</Label>
|
||
<Input
|
||
placeholder={t('labUi.rider.sponsorsPlaceholder')}
|
||
value={sponsors}
|
||
onChange={e => setSponsors(e.target.value)}
|
||
className="h-11 border-white/10 bg-black/50 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-[10px] text-gray-600">
|
||
⭐ {t('labUi.rider.goldBadgesHint1')}{' '}
|
||
<button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">{t('labUi.rider.tabBadges')}</button>
|
||
{t('labUi.rider.goldBadgesHint2')}
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* ── PARK fields ── */}
|
||
{cardType === 'park' && (
|
||
<div className="space-y-5 border-t border-white/8 pt-4">
|
||
<div className="flex items-start gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4 text-xs text-gray-400">
|
||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-400" />
|
||
<div>
|
||
<span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</span>{' '}
|
||
{t('labUi.rider.parkOwnersDesc')}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.parkName')}</Label>
|
||
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
|
||
placeholder={t('labUi.rider.parkNamePlaceholder')} className="h-11 border-white/10 bg-black/50" />
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.riderName')}</Label>
|
||
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
|
||
placeholder={t('labUi.rider.riderNamePlaceholder')} className="h-11 border-white/10 bg-black/50" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.riderNpub')}</Label>
|
||
<Input
|
||
value={recipientNpub}
|
||
onChange={e => setRecipientNpub(e.target.value)}
|
||
placeholder="npub1…"
|
||
className="h-11 border-white/10 bg-black/50 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.accessLevel')}</Label>
|
||
<Select value={accessLevel} onValueChange={setAccessLevel}>
|
||
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l =>
|
||
<SelectItem key={l} value={l}>{l}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.validThru')}</Label>
|
||
<Input type="date" value={validThru} onChange={e => setValidThru(e.target.value)}
|
||
placeholder="2026-12-31" className="h-11 border-white/10 bg-black/50" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="mb-1.5 block text-[10px] uppercase tracking-widest text-gray-500">{t('cardUi.venue')} *</Label>
|
||
{myVenues.length > 0 ? (
|
||
<>
|
||
<Select value={venueId} onValueChange={setVenueId}>
|
||
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venuePick')} /></SelectTrigger>
|
||
<SelectContent>
|
||
{myVenues.map(s => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="mt-1.5 text-[10px] text-gray-600">{t('cardUi.venueHint')}</p>
|
||
</>
|
||
) : (
|
||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-[11px] text-amber-400">
|
||
{t('cardUi.parkNoVenue')}{' '}
|
||
<Link to="/create-spot" className="underline">{t('cardUi.parkAddVenue')}</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Button
|
||
onClick={handlePublishParkCard}
|
||
disabled={!venueSpot || !recipientNpub.trim()}
|
||
className="h-12 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600] disabled:opacity-40"
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
{t('labUi.rider.publishToNostr')}
|
||
</Button>
|
||
|
||
{issuedPasses.length > 0 && (
|
||
<div className="border-t border-white/8 pt-4">
|
||
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.parkIssued')}</p>
|
||
<div className="space-y-2">
|
||
{issuedPasses.slice(0, 6).map((p) => (
|
||
<div key={p.id} className="flex items-center justify-between gap-2 text-[11px]">
|
||
<span className="truncate text-gray-400">
|
||
{p.holder ? `${nip19.npubEncode(p.holder).slice(0, 12)}…` : '—'}{p.accessLevel ? ` · ${p.accessLevel}` : ''}
|
||
</span>
|
||
{p.status === 'revoked' ? (
|
||
<span className="shrink-0 font-bold uppercase text-red-400">{t('cardUi.parkRevoked')}</span>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 shrink-0 border-white/10 text-[10px]"
|
||
disabled={revokingPass}
|
||
onClick={() => {
|
||
void revokePass(p)
|
||
.then(() => toast({ title: t('cardUi.parkRevoked') }))
|
||
.catch(() => toast({ title: t('cardUi.parkRevokeFailed'), variant: 'destructive' }));
|
||
}}
|
||
>
|
||
{t('cardUi.parkRevoke')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── CREW fields ── */}
|
||
{cardType === 'crew' && (
|
||
<div className="space-y-5 border-t border-white/8 pt-4">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.crew.pick')}</p>
|
||
{memberships.length > 0 ? (
|
||
<Select value={activeCrew?.id ?? ''} onValueChange={setCrewId}>
|
||
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{memberships.map(m => (
|
||
<SelectItem key={m.crew.id} value={m.crew.id}>{m.crew.name} · {m.role}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<p className="text-xs text-gray-500">{t('cardUi.crew.none')}</p>
|
||
)}
|
||
<p className="text-[10px] text-gray-600">{t('cardUi.crew.hint')}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── EVENT fields (pass / wristband) ── */}
|
||
{cardType === 'event' && (
|
||
<div className="space-y-5 border-t border-white/8 pt-4">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.event.pick')}</p>
|
||
{myEvents.length > 0 ? (
|
||
<Select value={activeEvent?.id ?? ''} onValueChange={setEventId}>
|
||
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{myEvents.map(ev => (
|
||
<SelectItem key={ev.id} value={ev.id}>{ev.title}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<p className="text-xs text-gray-500">{t('cardUi.event.none')}</p>
|
||
)}
|
||
<p className="text-[10px] text-gray-600">{t('cardUi.event.hint')}</p>
|
||
|
||
{activeEvent && user && activeEvent.pubkey === user.pubkey && (
|
||
<div className="space-y-3 border-t border-white/8 pt-4">
|
||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600">{t('cardUi.event.issuePass')}</p>
|
||
<Input
|
||
value={recipientNpub}
|
||
onChange={(e) => setRecipientNpub(e.target.value)}
|
||
placeholder="npub1…"
|
||
className="h-11 border-white/10 bg-black/50 font-mono text-xs"
|
||
/>
|
||
<Button
|
||
onClick={handlePublishEventPass}
|
||
disabled={!recipientNpub.trim()}
|
||
className="h-11 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600] disabled:opacity-40"
|
||
>
|
||
<Ticket className="h-4 w-4" />
|
||
{t('cardUi.event.issuePassBtn')}
|
||
</Button>
|
||
<p className="text-[10px] text-gray-600">{t('cardUi.event.issuePassHint')}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB: MY BADGES */}
|
||
{pageTab === 'badges' && (
|
||
<div className="mx-auto max-w-2xl space-y-6">
|
||
<div className="py-2 text-center">
|
||
<p className="mx-auto max-w-xs text-sm text-gray-400">{t('labUi.rider.badgesIntro')}</p>
|
||
</div>
|
||
<ReceivedBadgesPanel pubkey={user.pubkey} />
|
||
<div className="border-t border-white/8 pt-2">
|
||
<p className="mb-3 text-center text-xs text-gray-600">{t('labUi.rider.earnMore')}</p>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setPageTab('award')}
|
||
className="w-full gap-2 border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10"
|
||
>
|
||
<Award className="h-4 w-4" />
|
||
{t('labUi.rider.awardSomeone')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TAB: AWARD BADGE */}
|
||
{pageTab === 'award' && (
|
||
<div className="mx-auto max-w-2xl space-y-5">
|
||
<div className="flex items-center gap-3 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4">
|
||
<Award className="h-5 w-5 flex-shrink-0 text-yellow-400" />
|
||
<div>
|
||
<div className="text-sm font-bold text-yellow-300">{t('labUi.rider.youSawIt')}</div>
|
||
<div className="mt-0.5 text-xs text-gray-400">{t('labUi.rider.youSawItDesc')}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── Inline award form (used on the Award tab) ────────────────────────────────
|
||
function AwardBadgeInline({ issuerPubkey: _issuerPubkey, onSuccess }: { issuerPubkey: string; onSuccess: () => void }) {
|
||
const { t } = useTranslation();
|
||
const { mutate: publishEvent, isPending } = useNostrPublish();
|
||
const { toast } = useToast();
|
||
const [recipientNpub, setRecipientNpub] = useState('');
|
||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||
const [spotName, setSpotName] = useState('');
|
||
|
||
const toggleSkill = (id: string) =>
|
||
setSelectedSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||
|
||
const handleAward = () => {
|
||
if (!recipientNpub.trim() || selectedSkills.length === 0) {
|
||
toast({ title: t('labUi.rider.addRiderSkill'), variant: 'destructive' });
|
||
return;
|
||
}
|
||
let recipientHex = '';
|
||
try {
|
||
const decoded = nip19.decode(recipientNpub.trim());
|
||
if (decoded.type !== 'npub') throw new Error();
|
||
recipientHex = decoded.data;
|
||
} catch {
|
||
toast({ title: t('labUi.rider.invalidNpub'), variant: 'destructive' });
|
||
return;
|
||
}
|
||
const tags: string[][] = [
|
||
['p', recipientHex], ['t', 'skill-badge'],
|
||
['alt', `Skill badge awarded: ${selectedSkills.join(', ')}`],
|
||
...selectedSkills.map(s => ['skill', s]),
|
||
];
|
||
if (spotName.trim()) tags.push(['spot', spotName.trim()]);
|
||
publishEvent({ kind: SKILL_BADGE_KIND, content: '', tags }, {
|
||
onSuccess: () => {
|
||
toast({ title: t('labUi.rider.badgeAwarded'), description: t('labUi.rider.badgeSent', { skills: selectedSkills.join(', ') }) });
|
||
setRecipientNpub(''); setSelectedSkills([]); setSpotName('');
|
||
onSuccess();
|
||
},
|
||
onError: (e) => toast({ title: t('labUi.rider.failed'), description: String(e), variant: 'destructive' }),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderNpub')}</Label>
|
||
<Input
|
||
value={recipientNpub}
|
||
onChange={e => setRecipientNpub(e.target.value)}
|
||
placeholder="npub1…"
|
||
className="bg-black/50 border-white/10 font-mono text-xs h-11"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.skillsWitnessed')}</Label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{SKILL_OPTIONS.map(s => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => toggleSkill(s.id)}
|
||
className={cn(
|
||
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
|
||
selectedSkills.includes(s.id)
|
||
? 'bg-yellow-500/15 border-yellow-400/50 text-yellow-200'
|
||
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20'
|
||
)}
|
||
>
|
||
{s.icon} {s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.whereSeen')} <span className="normal-case text-gray-600">{t('labUi.rider.optional')}</span></Label>
|
||
<Input
|
||
value={spotName}
|
||
onChange={e => setSpotName(e.target.value)}
|
||
placeholder="MACBA, The Berrics, Burnside…"
|
||
className="bg-black/50 border-white/10 text-sm h-11"
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
onClick={handleAward}
|
||
disabled={isPending || selectedSkills.length === 0 || !recipientNpub.trim()}
|
||
className="w-full h-12 bg-yellow-500 hover:bg-yellow-400 text-black font-black uppercase gap-2"
|
||
>
|
||
<Award className="h-4 w-4" />
|
||
{isPending ? t('labUi.rider.signingOnNostr') : t('labUi.rider.awardBadge')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|