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.',
|
||||
},
|
||||
},
|
||||
role: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
organiser: 'Organiser',
|
||||
verifiedOwner: 'Verified owner',
|
||||
},
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue