feat(roles): owner/admin/organiser badges across pages + card studio

- New ControllerBadge (owner / admin / organiser / verified owner).
- Crew page hero: founder -> Owner, delegated admin -> Admin.
- Spot page: Owner; Shop page: 'Verified owner' (NIP-05 domain); Event page: Organiser.
- Card studio card-type chips show your role on each entity (Park=owner,
  Crew=owner/admin, Event=organiser) so it's clear what you control.
- i18n role.* EN/ES.
This commit is contained in:
SkateSpots 2026-10-02 13:30:04 +07:00
parent 892631e9f5
commit e9dce47d46
8 changed files with 71 additions and 14 deletions

View file

@ -0,0 +1,34 @@
import { Crown, ShieldCheck, BadgeCheck } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
type Role = 'owner' | 'admin' | 'organiser' | 'verifiedOwner';
const CONFIG: Record<Role, { key: string; cls: string; Icon: typeof Crown }> = {
owner: { key: 'owner', cls: 'border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]', Icon: Crown },
admin: { key: 'admin', cls: 'border-purple-400/40 bg-purple-400/10 text-purple-300', Icon: ShieldCheck },
organiser: { key: 'organiser', cls: 'border-blue-400/40 bg-blue-400/10 text-blue-300', Icon: ShieldCheck },
verifiedOwner: { key: 'verifiedOwner', cls: 'border-[#ccff00]/40 bg-[#ccff00]/10 text-[#ccff00]', Icon: BadgeCheck },
};
/**
* A small badge showing the viewer's control over the entity on screen
* (`owner`/`admin`/`organiser`) or a verified-ownership signal (`verifiedOwner`).
* Deliberately only rendered when it applies — no empty chrome.
*/
export function ControllerBadge({ role, className }: { role: Role; className?: string }) {
const { t } = useTranslation();
const { key, cls, Icon } = CONFIG[role];
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-black uppercase tracking-wide',
cls,
className,
)}
>
<Icon className="h-3 w-3" />
{t(`role.${key}`)}
</span>
);
}

View file

@ -6469,6 +6469,12 @@ walletUi: {
verifyNote: 'Scan the QR on the card to verify it.', verifyNote: 'Scan the QR on the card to verify it.',
}, },
}, },
role: {
owner: 'Owner',
admin: 'Admin',
organiser: 'Organiser',
verifiedOwner: 'Verified owner',
},
}; };
export default en; export default en;

View file

@ -6469,6 +6469,12 @@ walletUi: {
verifyNote: 'Escanea el QR del carné para verificarlo.', verifyNote: 'Escanea el QR del carné para verificarlo.',
}, },
}, },
role: {
owner: 'Propietario',
admin: 'Admin',
organiser: 'Organizador',
verifiedOwner: 'Propietario verificado',
},
}; };
export default es; export default es;

View file

@ -13,6 +13,7 @@ import { ZapCrewDialog } from '@/components/ZapCrewDialog';
import { ClinkPayButton } from '@/components/ClinkPayButton'; import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers'; import { CollectiveMembers } from '@/components/CollectiveMembers';
import { CrewHero } from '@/components/crew/CrewHero'; import { CrewHero } from '@/components/crew/CrewHero';
import { ControllerBadge } from '@/components/ControllerBadge';
import { CrewLeadership } from '@/components/crew/CrewLeadership'; import { CrewLeadership } from '@/components/crew/CrewLeadership';
import { CrewSponsorBanner } from '@/components/crew/CrewSponsorBanner'; import { CrewSponsorBanner } from '@/components/crew/CrewSponsorBanner';
import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard'; import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard';
@ -190,6 +191,8 @@ export default function CrewDetailPage() {
actions={ actions={
(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') ? ( (canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') ? (
<> <>
{isFounder && <ControllerBadge role="owner" />}
{isAdmin && !isFounder && <ControllerBadge role="admin" />}
{canManage && <CrewInviteDialog crew={crew} isFounder={isFounder} />} {canManage && <CrewInviteDialog crew={crew} isFounder={isFounder} />}
{isFounder && ( {isFounder && (
<> <>

View file

@ -30,6 +30,7 @@ import { Users } from 'lucide-react';
import NotFound from './NotFound'; import NotFound from './NotFound';
import { Calendar, Clock, MapPin, ArrowLeft, Share2, Navigation, CalendarPlus, Pencil, Music, Megaphone, XCircle, CalendarClock } from 'lucide-react'; import { Calendar, Clock, MapPin, ArrowLeft, Share2, Navigation, CalendarPlus, Pencil, Music, Megaphone, XCircle, CalendarClock } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { ControllerBadge } from '@/components/ControllerBadge';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -411,6 +412,7 @@ export default function EventDetailPage() {
{event.title} {event.title}
</h1> </h1>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
{user?.pubkey === event.pubkey && <ControllerBadge role="organiser" />}
{crewHost && ( {crewHost && (
<Link <Link
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`} to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}

View file

@ -268,6 +268,13 @@ export default function RiderCardPage() {
}, [allEvents, user?.pubkey, rsvpCoords]); }, [allEvents, user?.pubkey, rsvpCoords]);
const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0]; const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0];
const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined); const eventRsvp = useEventRsvps(cardType === 'event' && activeEvent ? eventCoord(activeEvent) : undefined);
// Which card entities does this account actually control?
const crewRoleOnCard: 'owner' | 'admin' | undefined =
memberships.some((m) => m.crew.pubkey === user?.pubkey) ? 'owner'
: memberships.some((m) => user?.pubkey && m.crew.admins.includes(user.pubkey)) ? 'admin'
: undefined;
const organisesEvent = myEvents.some((e) => e.pubkey === user?.pubkey);
const myVenues = useMemo( const myVenues = useMemo(
() => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey), () => (allSpots ?? []).filter((s) => s.pubkey === user?.pubkey),
[allSpots, user?.pubkey], [allSpots, user?.pubkey],
@ -719,11 +726,11 @@ export default function RiderCardPage() {
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-gray-600">{t('labUi.rider.cardType')}</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"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{([ {([
{ id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User }, { id: 'rider' as const, label: t('labUi.rider.licenseLabel'), icon: User, role: undefined },
...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2 }] : []), ...(myVenues.length > 0 ? [{ id: 'park' as const, label: t('labUi.rider.parkLabel'), icon: Building2, role: 'owner' as const }] : []),
...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users }] : []), ...(memberships.length > 0 ? [{ id: 'crew' as const, label: t('cardUi.crew.crewCard'), icon: Users, role: crewRoleOnCard }] : []),
...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket }] : []), ...(myEvents.length > 0 ? [{ id: 'event' as const, label: t('cardUi.event.pass'), icon: Ticket, role: organisesEvent ? ('organiser' as const) : undefined }] : []),
]).map(({ id, label, icon: Icon }) => ( ]).map(({ id, label, icon: Icon, role }) => (
<button <button
key={id} key={id}
onClick={() => setCardType(id)} onClick={() => setCardType(id)}
@ -736,6 +743,7 @@ export default function RiderCardPage() {
> >
<Icon className={cn('h-4 w-4', cardType === id ? 'text-[#ff0040]' : 'text-gray-600')} /> <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> <span className="text-[9px] font-black uppercase leading-tight">{label}</span>
{role && <span className="text-[7px] font-black uppercase tracking-wide text-[#ccff00]">{t(`role.${role}`)}</span>}
</button> </button>
))} ))}
</div> </div>

View file

@ -10,7 +10,6 @@ import { SpotMap } from '@/components/SpotMap';
import { RouteDialog } from '@/components/RouteDialog'; import { RouteDialog } from '@/components/RouteDialog';
import type { SkateSpot } from '@/lib/types'; import type { SkateSpot } from '@/lib/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -19,6 +18,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CommentsSection } from '@/components/comments/CommentsSection'; import { CommentsSection } from '@/components/comments/CommentsSection';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge'; import { OfficialBadge } from '@/components/OfficialBadge';
import { ControllerBadge } from '@/components/ControllerBadge';
import { useShop } from '@/hooks/useShops'; import { useShop } from '@/hooks/useShops';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useDeleteEntity } from '@/hooks/useDeleteEntity'; import { useDeleteEntity } from '@/hooks/useDeleteEntity';
@ -42,7 +42,6 @@ import {
Globe, Globe,
Instagram, Instagram,
Clock, Clock,
CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -276,12 +275,7 @@ export default function ShopDetailPage() {
<h1 className="text-3xl font-black uppercase neon-lime" style={{ <h1 className="text-3xl font-black uppercase neon-lime" style={{
fontFamily: 'Impact, "Arial Black", sans-serif' fontFamily: 'Impact, "Arial Black", sans-serif'
}}>{shop.name}</h1> }}>{shop.name}</h1>
{isVerifiedOwner && ( {isVerifiedOwner && <ControllerBadge role="verifiedOwner" />}
<Badge className="bg-blue-500/20 text-blue-400 border border-blue-500/50">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('common.verified')}
</Badge>
)}
<OfficialBadge pubkey={shop.pubkey} size="md" /> <OfficialBadge pubkey={shop.pubkey} size="md" />
</div> </div>
{shop.location && ( {shop.location && (

View file

@ -6,6 +6,7 @@ import ngeohash from 'ngeohash';
import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox'; import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { PageError } from '@/components/ErrorState'; import { PageError } from '@/components/ErrorState';
import { ControllerBadge } from '@/components/ControllerBadge';
import { SpotMap } from '@/components/SpotMap'; import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -373,7 +374,10 @@ export default function SpotDetailPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="text-2xl sm:text-3xl font-bold break-words">{spot.title}</h1> <div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl sm:text-3xl font-bold break-words">{spot.title}</h1>
{isOwner && <ControllerBadge role="owner" />}
</div>
{spot.location && ( {spot.location && (
<p className="text-muted-foreground flex items-start gap-2 mt-2 text-sm sm:text-base"> <p className="text-muted-foreground flex items-start gap-2 mt-2 text-sm sm:text-base">
<MapPin className="h-4 w-4 flex-shrink-0 mt-0.5" /> <MapPin className="h-4 w-4 flex-shrink-0 mt-0.5" />