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.',
},
},
role: {
owner: 'Owner',
admin: 'Admin',
organiser: 'Organiser',
verifiedOwner: 'Verified owner',
},
};
export default en;

View file

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

View file

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

View file

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

View file

@ -268,6 +268,13 @@ export default function RiderCardPage() {
}, [allEvents, user?.pubkey, rsvpCoords]);
const activeEvent = myEvents.find(ev => ev.id === eventId) ?? myEvents[0];
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(
() => (allSpots ?? []).filter((s) => s.pubkey === 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>
<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 }) => (
{ 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, role: 'owner' as const }] : []),
...(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, role: organisesEvent ? ('organiser' as const) : undefined }] : []),
]).map(({ id, label, icon: Icon, role }) => (
<button
key={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')} />
<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>
))}
</div>

View file

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

View file

@ -6,6 +6,7 @@ import ngeohash from 'ngeohash';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox, type LightboxImage } from '@/components/ImageLightbox';
import { PageError } from '@/components/ErrorState';
import { ControllerBadge } from '@/components/ControllerBadge';
import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -373,7 +374,10 @@ export default function SpotDetailPage() {
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<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 && (
<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" />