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:
parent
892631e9f5
commit
e9dce47d46
8 changed files with 71 additions and 14 deletions
34
src/components/ControllerBadge.tsx
Normal file
34
src/components/ControllerBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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}`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue