feat(card): Card Studio redesign + drop self-claimed skills
- Two-pane studio: sticky live preview (desktop) + inspector column; mobile stacks preview-first. - Actions decluttered to a single primary Share + a '...' menu (Save PNG / Print / Copy ID); removed the competing big buttons. - Unsaved-changes state + manual Save changes button (no autosave, so an interactive signer like Amber isn't prompted per keystroke). - Card type is now a compact 4-up chip row; the two toggle grids merged into ONE 'Show on card' list with live values. - Skills: removed the self-claimed skill chips (marginal/unverifiable). The card shows witnessed badges (kind 1293) only; style + level are now DERIVED from the Skater profile (kind 35000) instead of a manual 'Skate Style' select. - React card + canvas export updated; i18n cardUi.studio.*; NIP.md + AGENTS.
This commit is contained in:
parent
7cafcc0dfa
commit
9891101452
6 changed files with 389 additions and 383 deletions
|
|
@ -95,6 +95,15 @@ session with **no other memory of this project**. So:
|
|||
Shows title/date/venue/cover + **who it admits** + going count.
|
||||
**Claims tab (2026-10-02):** `/review` has a **Claims** tab (pending
|
||||
private-spot ownership claims; was a banner above the tabs).
|
||||
**Card Studio (2026-10-02):** `/card`'s "My Card" tab is now a two-pane studio
|
||||
— **sticky live preview** (lg) + inspector; actions collapsed to **Share** +
|
||||
a `⋯` menu (PNG/print/copy); a **dirty/saved** state with a manual **Save
|
||||
changes** (no autosave — an interactive signer must not prompt per keystroke);
|
||||
card **type = compact chips**; the two toggle grids merged into **one "Show on
|
||||
card" list** with live values. **Self-claimed skills removed** (were marginal/
|
||||
unverifiable) — the card shows **witnessed badges** (kind 1293) only, and
|
||||
**style/level are DERIVED** from the Skater profile (kind 35000). The canvas
|
||||
export + `CardData` dropped the self-claimed skills row and gained `level`.
|
||||
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
|
||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||||
|
|
|
|||
7
NIP.md
7
NIP.md
|
|
@ -1560,8 +1560,11 @@ author's kind 0; on-chain stats are derived live:
|
|||
- `v:2` marks the `show` flags as explicit, so hiding a facet persists. An event
|
||||
with **no** `v`/`show` tags defaults every facet to visible; `v:1` cards (which
|
||||
never had facet flags) also default the three facets ON rather than hiding them.
|
||||
- The card shows **self-claimed** skills (`skill` tags) in the theme colour,
|
||||
**witnessed** badges (kind 1293) in gold ⭐, and **Lab roles** as blue chips.
|
||||
- The card shows **witnessed** badges (kind 1293) in gold ⭐ and **Lab roles** as
|
||||
blue chips. Self-claimed `skill` tags are **no longer written** (unverifiable,
|
||||
marginal) and `style` is no longer stored — **style + level are DERIVED from the
|
||||
Skater profile** (kind 35000). `parseRiderCard` still reads legacy `style`/
|
||||
`skill` tags so old cards don't break.
|
||||
- **Identity facets are derived LIVE, never stored on the card:** crews come from
|
||||
the author's crew memberships (kind 35006), the local from their skater profile
|
||||
(kind 35000 `location`/`home_spot`), and the roles from their own Lab profiles
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export interface CardData {
|
|||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
/** Derived level from the Skater profile (kind 35000). */
|
||||
level?: string;
|
||||
skills: string[];
|
||||
sponsors: string;
|
||||
ridesFor: string;
|
||||
|
|
@ -201,15 +203,20 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
{d.style}
|
||||
</span>
|
||||
)}
|
||||
{d.level && (
|
||||
<span className="text-[5.5px] font-bold uppercase px-[4px] py-[1px] rounded-[3px] w-fit leading-none" style={{ background: 'rgba(255,102,0,0.18)', border: '1px solid rgba(255,102,0,0.45)', color: '#ff9944' }}>
|
||||
{d.level}
|
||||
</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 — roles, self-claimed, then witnessed (gold) */}
|
||||
{/* Roles + witnessed badges only — self-claimed skills are gone */}
|
||||
<div className="flex flex-wrap gap-[2px] mt-[2px]">
|
||||
{d.showRoles !== false && (d.roles ?? []).slice(0, 2).map(r => {
|
||||
{d.showRoles !== false && (d.roles ?? []).slice(0, 3).map(r => {
|
||||
const m = roleMeta(r);
|
||||
return m ? (
|
||||
<span key={r} className="text-[5px] font-bold px-[3px] py-[1px] rounded-[2px] leading-none" style={{ background: 'rgba(100,160,255,0.18)', border: '1px solid rgba(100,160,255,0.45)', color: '#9cc4ff' }}>
|
||||
|
|
@ -217,14 +224,6 @@ function RiderFront({ d }: { d: CardData }) {
|
|||
</span>
|
||||
) : null;
|
||||
})}
|
||||
{d.skills.slice(0, 3).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 ? (
|
||||
|
|
|
|||
|
|
@ -6373,6 +6373,16 @@ walletUi: {
|
|||
verifiedEvent: 'Verified event',
|
||||
viewEvent: 'View the event',
|
||||
},
|
||||
studio: {
|
||||
unsaved: 'Unsaved changes',
|
||||
saved: 'Saved',
|
||||
saveChanges: 'Save changes',
|
||||
allSaved: 'Saved — up to date',
|
||||
showOnCard: 'Show on card',
|
||||
derived: 'From your Skater profile',
|
||||
noSkaterProfile: 'No Skater profile yet —',
|
||||
editSkaterProfile: 'add your styles & level',
|
||||
},
|
||||
verify: {
|
||||
checking: 'Checking the card…',
|
||||
notFound: 'Card not found',
|
||||
|
|
|
|||
|
|
@ -6373,6 +6373,16 @@ walletUi: {
|
|||
verifiedEvent: 'Evento verificado',
|
||||
viewEvent: 'Ver el evento',
|
||||
},
|
||||
studio: {
|
||||
unsaved: 'Cambios sin guardar',
|
||||
saved: 'Guardado',
|
||||
saveChanges: 'Guardar cambios',
|
||||
allSaved: 'Guardado — al día',
|
||||
showOnCard: 'Mostrar en el carné',
|
||||
derived: 'Desde tu perfil de skater',
|
||||
noSkaterProfile: 'Aún no tienes perfil de skater —',
|
||||
editSkaterProfile: 'añade tus estilos y nivel',
|
||||
},
|
||||
verify: {
|
||||
checking: 'Comprobando el carné…',
|
||||
notFound: 'Carné no encontrado',
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ import { nip19 } from 'nostr-tools';
|
|||
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { RiderCardFront, type CardData, type BadgeAward } from '@/components/RiderCard';
|
||||
import { THEMES, SKILL_OPTIONS, STYLES, fullNpub, memberSince, roleMeta, formatCardDate, type ThemeId } from '@/lib/riderCard';
|
||||
import { THEMES, SKILL_OPTIONS, fullNpub, memberSince, roleMeta, formatCardDate, 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';
|
||||
|
||||
|
|
@ -47,9 +48,10 @@ 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 } from '@/lib/types';
|
||||
import { SKATE_SPOT_KIND, SKATE_CREW_KIND, CALENDAR_EVENT_KIND, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Printer, Loader2, Share2, Download, Check, Copy, Award, Zap, Info, Palette, ChevronUp, ChevronDown, User, Building2, Users, Ticket } from 'lucide-react';
|
||||
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 } from 'lucide-react';
|
||||
// ─── Card canvas renderer ─────────────────────────────────────────────────────
|
||||
// Draws the card entirely via Canvas 2D API — 100% reliable export,
|
||||
// no CSS capture, no scaling issues, no background-stripping.
|
||||
|
|
@ -67,6 +69,7 @@ interface CanvasCardData {
|
|||
pubkey: string;
|
||||
npub: string;
|
||||
style: string;
|
||||
level?: string;
|
||||
skills: string[];
|
||||
sponsors: string;
|
||||
ridesFor: string;
|
||||
|
|
@ -346,6 +349,22 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
ry += 22;
|
||||
}
|
||||
|
||||
// Level chip (derived from the Skater profile)
|
||||
if (data.level) {
|
||||
ctx.font = 'bold 16px system-ui, sans-serif';
|
||||
const lvlTxt = data.level.toUpperCase();
|
||||
const lvlW = ctx.measureText(lvlTxt).width + 24;
|
||||
roundRect(ctx, RX, ry - 18, lvlW, 26, 6);
|
||||
ctx.fillStyle = 'rgba(255,102,0,0.18)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,102,0,0.45)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ff9944';
|
||||
ctx.fillText(lvlTxt, RX + 12, ry - 1);
|
||||
ry += 22;
|
||||
}
|
||||
|
||||
// Local + crew crests (identity facets)
|
||||
if (data.local) {
|
||||
ctx.fillStyle = p.accent;
|
||||
|
|
@ -397,11 +416,7 @@ async function drawCardToCanvas(data: CanvasCardData): Promise<string> {
|
|||
const m = roleMeta(r);
|
||||
return m ? { label: `${m.emoji} ${m.label}`, gold: false } : null;
|
||||
}).filter(Boolean),
|
||||
...data.skills.slice(0, 4).map(id => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === id);
|
||||
return s ? { label: s.label, gold: false } : null;
|
||||
}).filter(Boolean),
|
||||
...data.receivedBadges.slice(0, 3).map(b => {
|
||||
...data.receivedBadges.slice(0, 4).map(b => {
|
||||
const s = SKILL_OPTIONS.find(o => o.id === b.skill);
|
||||
return s ? { label: `★ ${s.label}`, gold: true } : null;
|
||||
}).filter(Boolean),
|
||||
|
|
@ -824,6 +839,14 @@ export default function RiderCardPage() {
|
|||
[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 ?? '');
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -853,8 +876,6 @@ export default function RiderCardPage() {
|
|||
const [holographic, setHolographic] = useState(true);
|
||||
|
||||
// Rider fields
|
||||
const [style, setStyle] = useState('Street');
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const [sponsors, setSponsors] = useState('');
|
||||
const [ridesFor, setRidesFor] = useState('');
|
||||
const [showSpots, setShowSpots] = useState(true);
|
||||
|
|
@ -904,8 +925,6 @@ export default function RiderCardPage() {
|
|||
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);
|
||||
|
|
@ -916,6 +935,18 @@ export default function RiderCardPage() {
|
|||
setShowRoles(c.showRoles);
|
||||
}, [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
|
||||
);
|
||||
}, [savedCard, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles]);
|
||||
|
||||
const recipientPubkey = (() => {
|
||||
if (!recipientNpub.trim()) return user?.pubkey ?? '';
|
||||
try {
|
||||
|
|
@ -939,7 +970,10 @@ export default function RiderCardPage() {
|
|||
avatar: cardType === 'crew' ? activeCrew?.image : cardType === 'event' ? activeEvent?.image : metadata?.picture,
|
||||
pubkey: cardPubkey,
|
||||
npub: cardPubkey ? fullNpub(cardPubkey) : '',
|
||||
style, skills, sponsors, ridesFor,
|
||||
style: derivedStyle,
|
||||
level: derivedLevel || undefined,
|
||||
skills: [],
|
||||
sponsors, ridesFor,
|
||||
showSpots, showZaps, showEvents,
|
||||
crews: crewCrests,
|
||||
local: local || undefined,
|
||||
|
|
@ -987,7 +1021,7 @@ export default function RiderCardPage() {
|
|||
}),
|
||||
[
|
||||
cardType, theme, holographic, displayName, recipientName, metadata,
|
||||
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
|
||||
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,
|
||||
],
|
||||
|
|
@ -1020,7 +1054,7 @@ export default function RiderCardPage() {
|
|||
|
||||
const handleSaveCard = useCallback(() => {
|
||||
if (!user) return;
|
||||
const config: RiderCardConfig = { theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles };
|
||||
const config: RiderCardConfig = { theme, style: '', skills: [], sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles };
|
||||
publishEvent(
|
||||
{ kind: RIDER_CARD_KIND, content: '', tags: riderCardTags(config) },
|
||||
{
|
||||
|
|
@ -1028,7 +1062,7 @@ export default function RiderCardPage() {
|
|||
onError: (e) => toast({ title: t('cardUi.saveFailed'), description: String(e), variant: 'destructive' }),
|
||||
},
|
||||
);
|
||||
}, [user, theme, style, skills, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, publishEvent, toast, t]);
|
||||
}, [user, theme, sponsors, ridesFor, showSpots, showZaps, showEvents, showCrews, showLocal, showRoles, publishEvent, toast, t]);
|
||||
|
||||
const handlePrint = useCallback(async () => {
|
||||
const dataUrl = await renderCardToImage();
|
||||
|
|
@ -1089,9 +1123,6 @@ export default function RiderCardPage() {
|
|||
});
|
||||
}, [user, recipientPubkey, issuerName, accessLevel, validThru, theme, venueSpot, publishEvent, toast, t]);
|
||||
|
||||
const toggleSkill = (id: string) =>
|
||||
setSkills(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||||
|
||||
// ── Logged out ────────────────────────────────────────────────────────────
|
||||
if (!user) {
|
||||
return (
|
||||
|
|
@ -1120,7 +1151,7 @@ export default function RiderCardPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-2xl mx-auto px-4 pb-16">
|
||||
<div className="mx-auto max-w-5xl px-4 pb-24">
|
||||
|
||||
{/* ── Page header ── */}
|
||||
<div className="pt-8 pb-6 text-center">
|
||||
|
|
@ -1131,8 +1162,8 @@ export default function RiderCardPage() {
|
|||
<p className="text-gray-500 text-xs tracking-widest uppercase">{t('labUi.rider.headerTagline')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Top tabs: My Card / My Badges / Award Badge ── */}
|
||||
<div className="flex rounded-xl overflow-hidden border border-white/10 mb-8 bg-black/40">
|
||||
{/* ── 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})` : ''}` },
|
||||
|
|
@ -1143,9 +1174,7 @@ export default function RiderCardPage() {
|
|||
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:text-gray-300 hover:bg-white/5'
|
||||
pageTab === tab.id ? 'bg-[#ff0040] text-white' : 'text-gray-500 hover:bg-white/5 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
|
|
@ -1153,398 +1182,347 @@ export default function RiderCardPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* ══════════════════════════════════════════════
|
||||
TAB: MY CARD
|
||||
══════════════════════════════════════════════ */}
|
||||
{pageTab === 'card' && (
|
||||
<div className="space-y-8">
|
||||
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_400px] lg:items-start lg:gap-8">
|
||||
|
||||
{/* ── Hero card preview ── */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Stage */}
|
||||
{/* ══ LEFT: sticky preview + actions ══ */}
|
||||
<div className="space-y-4 lg:sticky lg:top-20">
|
||||
<div
|
||||
className="w-full rounded-2xl flex items-center justify-center py-8 relative overflow-hidden"
|
||||
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)' }}
|
||||
>
|
||||
{/* Subtle glow behind card */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-32 blur-3xl opacity-30"
|
||||
<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)` }}
|
||||
/>
|
||||
<RiderCardFront d={cardData} printRef={printRef} />
|
||||
</div>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={rendering}
|
||||
size="lg"
|
||||
className="w-full h-14 bg-gradient-to-r from-[#ff0040] to-[#ff6600] hover:opacity-90 font-black uppercase text-base rounded-xl shadow-xl shadow-[#ff0040]/20 gap-2"
|
||||
>
|
||||
{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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Secondary actions row */}
|
||||
<div className="grid grid-cols-3 gap-2 w-full">
|
||||
{/* Primary action: Share + More */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
onClick={handleShare}
|
||||
disabled={rendering}
|
||||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||||
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"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{t('labUi.rider.savePng')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrint}
|
||||
disabled={rendering}
|
||||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
{t('labUi.rider.print')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopyNpub}
|
||||
className="gap-1.5 border-white/10 hover:border-white/30 text-xs font-bold uppercase h-10"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? t('common.copied') : t('labUi.rider.copyId')}
|
||||
{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 the card to Nostr so it persists & verifies */}
|
||||
{/* Save (personal card only) */}
|
||||
{cardType === 'rider' && (
|
||||
<Button
|
||||
onClick={handleSaveCard}
|
||||
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||||
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" />
|
||||
{t('cardUi.save')}
|
||||
{isDirty ? t('cardUi.studio.saveChanges') : t('cardUi.studio.allSaved')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Card type toggle ── */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2">{t('labUi.rider.cardType')}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ id: 'rider', label: t('labUi.rider.licenseLabel'), sub: t('labUi.rider.licenseSub'), icon: User },
|
||||
{ id: 'park', label: t('labUi.rider.parkLabel'), sub: t('labUi.rider.parkSub'), icon: Building2 },
|
||||
{ id: 'crew', label: t('cardUi.crew.crewCard'), sub: t('cardUi.crew.crewSub'), icon: Users },
|
||||
{ id: 'event', label: t('cardUi.event.pass'), sub: t('cardUi.event.passSub'), icon: Ticket },
|
||||
] as const).map(({ id, label, sub, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setCardType(id)}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border text-left transition-all',
|
||||
cardType === id
|
||||
? 'bg-[#ff0040]/10 border-[#ff0040]/60 text-white'
|
||||
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-5 w-5 mb-2', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} />
|
||||
<div className="text-xs font-black uppercase">{label}</div>
|
||||
<div className="text-[10px] mt-0.5 opacity-60">{sub}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* ══ RIGHT: inspector ══ */}
|
||||
<div className="mt-8 space-y-6 lg:mt-0">
|
||||
|
||||
{/* ── Theme picker ── */}
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 mb-2 flex items-center gap-1.5">
|
||||
<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(
|
||||
'aspect-square rounded-xl border-2 transition-all flex flex-col items-center justify-center gap-0.5',
|
||||
`card-theme-${th.id}`,
|
||||
theme === th.id
|
||||
? 'ring-2 ring-white/50 ring-offset-1 ring-offset-black scale-105 border-white/30'
|
||||
: 'border-white/10 opacity-60 hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
<span className="text-xl">{th.emoji}</span>
|
||||
<span className="text-[8px] font-black uppercase opacity-80">{th.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Holographic toggle ── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 rounded-xl bg-white/3 border border-white/8">
|
||||
{/* Card type */}
|
||||
<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>
|
||||
|
||||
{/* ── RIDER fields ── */}
|
||||
{cardType === 'rider' && (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.cardDetails')}</p>
|
||||
|
||||
{/* Style */}
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.skateStyle')}</Label>
|
||||
<Select value={style} onValueChange={setStyle}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STYLES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Rides For + Sponsors — side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.ridesFor')}</Label>
|
||||
<Input
|
||||
placeholder={t('labUi.rider.ridesForPlaceholder')}
|
||||
value={ridesFor}
|
||||
onChange={e => setRidesFor(e.target.value)}
|
||||
className="bg-black/50 border-white/10 text-sm h-11"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.sponsors')}</Label>
|
||||
<Input
|
||||
placeholder={t('labUi.rider.sponsorsPlaceholder')}
|
||||
value={sponsors}
|
||||
onChange={e => setSponsors(e.target.value)}
|
||||
className="bg-black/50 border-white/10 text-sm h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats toggles */}
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('labUi.rider.showOnCard')}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: t('labUi.pyramid.spots'), value: stats?.spotsAdded ?? 0, state: showSpots, set: setShowSpots },
|
||||
{ label: t('labUi.rider.zaps'), value: stats?.zapsReceived ?? 0, state: showZaps, set: setShowZaps },
|
||||
{ label: t('labUi.pyramid.events'), value: stats?.eventsCreated ?? 0, state: showEvents, set: setShowEvents },
|
||||
].map(({ label, value, state, set }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => set(!state)}
|
||||
className={cn(
|
||||
'p-3 rounded-xl border text-center transition-all',
|
||||
state
|
||||
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
|
||||
: 'bg-black/30 border-white/8 text-gray-600'
|
||||
)}
|
||||
>
|
||||
<div className={cn('text-lg font-black', state ? 'text-[#ff0040]' : 'text-gray-700')}>{value}</div>
|
||||
<div className="text-[9px] uppercase tracking-wide mt-0.5">{label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Identity facets */}
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-2 block">{t('cardUi.facets.title')}</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: t('cardUi.facets.crews'), value: crewCrests.length, state: showCrews, set: setShowCrews },
|
||||
{ label: t('cardUi.facets.local'), value: local ? '📍' : '—', state: showLocal, set: setShowLocal },
|
||||
{ label: t('cardUi.facets.roles'), value: roles.length, state: showRoles, set: setShowRoles },
|
||||
].map(({ label, value, state, set }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => set(!state)}
|
||||
className={cn(
|
||||
'p-3 rounded-xl border text-center transition-all',
|
||||
state
|
||||
? 'bg-[#ff0040]/10 border-[#ff0040]/40 text-white'
|
||||
: 'bg-black/30 border-white/8 text-gray-600'
|
||||
)}
|
||||
>
|
||||
<div className={cn('text-lg font-black', state ? 'text-[#ff0040]' : 'text-gray-700')}>{value}</div>
|
||||
<div className="text-[9px] uppercase tracking-wide mt-0.5">{label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500">{t('labUi.rider.skills')}</Label>
|
||||
<span className="text-[10px] text-gray-600">{t('labUi.rider.selected', { count: skills.length })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SKILL_OPTIONS.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => toggleSkill(s.id)}
|
||||
disabled={!skills.includes(s.id) && skills.length >= 6}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 rounded-lg text-xs font-bold border transition-all',
|
||||
skills.includes(s.id)
|
||||
? 'bg-[#ff0040]/15 border-[#ff0040]/50 text-white'
|
||||
: 'bg-black/30 border-white/8 text-gray-500 hover:border-white/20 disabled:opacity-25 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{s.icon} {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-600 mt-2">
|
||||
⭐ {t('labUi.rider.goldBadgesHint1')} <button onClick={() => setPageTab('badges')} className="text-yellow-500 underline">{t('labUi.rider.tabBadges')}</button>{t('labUi.rider.goldBadgesHint2')}
|
||||
</p>
|
||||
<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', label: t('labUi.rider.licenseLabel'), icon: User },
|
||||
{ id: 'park', label: t('labUi.rider.parkLabel'), icon: Building2 },
|
||||
{ id: 'crew', label: t('cardUi.crew.crewCard'), icon: Users },
|
||||
{ id: 'event', label: t('cardUi.event.pass'), icon: Ticket },
|
||||
] as const).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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PARK fields ── */}
|
||||
{cardType === 'park' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/5 border border-blue-500/20 text-xs text-gray-400">
|
||||
<Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold text-blue-300">{t('labUi.rider.parkOwnersOnly')}</span>{' '}
|
||||
{t('labUi.rider.parkOwnersDesc')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('labUi.rider.parkDetails')}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.parkName')}</Label>
|
||||
<Input value={issuerName} onChange={e => setIssuerName(e.target.value)}
|
||||
placeholder={t('labUi.rider.parkNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.riderName')}</Label>
|
||||
<Input value={recipientName} onChange={e => setRecipientName(e.target.value)}
|
||||
placeholder={t('labUi.rider.riderNamePlaceholder')} className="bg-black/50 border-white/10 h-11" />
|
||||
</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>
|
||||
<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 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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Show on card — ONE clear list */}
|
||||
{cardType === 'rider' && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.accessLevel')}</Label>
|
||||
<Select value={accessLevel} onValueChange={setAccessLevel}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><SelectValue /></SelectTrigger>
|
||||
<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>
|
||||
|
||||
<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="text" 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>
|
||||
<Select value={venueId || '__none'} onValueChange={(v) => setVenueId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="h-11 border-white/10 bg-black/50"><SelectValue placeholder={t('cardUi.venueNone')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{['Full Access','Supervised','Beginner Zone','Street Only','Bowl Only','Members Only'].map(l =>
|
||||
<SelectItem key={l} value={l}>{l}</SelectItem>)}
|
||||
<SelectItem value="__none">{t('cardUi.venueNone')}</SelectItem>
|
||||
{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>
|
||||
<div>
|
||||
<Label className="text-[10px] uppercase tracking-widest text-gray-500 mb-1.5 block">{t('labUi.rider.validThru')}</Label>
|
||||
<Input type="text" value={validThru} onChange={e => setValidThru(e.target.value)}
|
||||
placeholder="2026-12-31" className="bg-black/50 border-white/10 h-11" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handlePublishParkCard}
|
||||
className="h-12 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#b8e600]"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{t('labUi.rider.publishToNostr')}
|
||||
</Button>
|
||||
</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>
|
||||
{/* ── 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>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handlePublishParkCard}
|
||||
className="w-full h-12 bg-[#ccff00] hover:bg-[#b8e600] text-black font-black uppercase gap-2"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
{t('labUi.rider.publishToNostr')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CREW fields ── */}
|
||||
{cardType === 'crew' && (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.crew.pick')}</p>
|
||||
{memberships.length > 0 ? (
|
||||
<Select value={activeCrew?.id ?? ''} onValueChange={setCrewId}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><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">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-gray-600 border-t border-white/8 pt-4">{t('cardUi.event.pick')}</p>
|
||||
{myEvents.length > 0 ? (
|
||||
<Select value={activeEvent?.id ?? ''} onValueChange={setEventId}>
|
||||
<SelectTrigger className="bg-black/50 border-white/10 h-11"><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>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════════════════════════════════════════
|
||||
TAB: MY BADGES
|
||||
══════════════════════════════════════════════ */}
|
||||
{/* TAB: MY BADGES */}
|
||||
{pageTab === 'badges' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-2">
|
||||
<p className="text-gray-400 text-sm max-w-xs mx-auto">
|
||||
{t('labUi.rider.badgesIntro')}
|
||||
</p>
|
||||
<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="pt-2 border-t border-white/8">
|
||||
<p className="text-xs text-gray-600 text-center mb-3">{t('labUi.rider.earnMore')}</p>
|
||||
<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 border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 gap-2"
|
||||
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')}
|
||||
|
|
@ -1553,20 +1531,17 @@ export default function RiderCardPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ══════════════════════════════════════════════
|
||||
TAB: AWARD BADGE (inline, no dialog)
|
||||
══════════════════════════════════════════════ */}
|
||||
{/* TAB: AWARD BADGE */}
|
||||
{pageTab === 'award' && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-yellow-500/5 border border-yellow-500/20">
|
||||
<Award className="h-5 w-5 text-yellow-400 flex-shrink-0" />
|
||||
<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="text-xs text-gray-400 mt-0.5">{t('labUi.rider.youSawItDesc')}</div>
|
||||
<div className="mt-0.5 text-xs text-gray-400">{t('labUi.rider.youSawItDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline award form — the dialog was removed as dead code */}
|
||||
<AwardBadgeInline issuerPubkey={user.pubkey} onSuccess={() => setPageTab('badges')} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue