Mutual crew<->Lab link verification (trust badge) + polish
The crew<->collective links existed on both sides (crew.labRefs, profile.crewRef) but were unverified one-sided claims. Now: - CrossLinkCards resolve the other entity and show a green "Verified" badge only when BOTH sides link each other (crew lists the Lab profile AND the profile lists the crew). One-sided claims show a plain link — never false trust. - LinkedCrewCard / LinkedLabCard take a selfCoord; CoachDetailPage and CrewDetailPage pass their own coordinate so the check works. - CrewLinkField + LabLinkField now explain the mutual-verified behavior, so the feature is discoverable. Both fields already restrict to the user's OWN entities, so a verified link genuinely requires one owner to confirm both sides. - Completed LabLinkField TYPE_LABEL for all crafts (artist/graffiti/tattoo/ trainer/musician/judge no longer fall back to "Lab"). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
a89fca8e28
commit
64b91c0ae2
5 changed files with 81 additions and 18 deletions
|
|
@ -39,6 +39,8 @@ export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', a
|
||||||
</Label>
|
</Label>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Run a crew too? Link it so people can jump between your studio and your crew. Optional.
|
Run a crew too? Link it so people can jump between your studio and your crew. Optional.
|
||||||
|
Link this crew back to the profile from the crew editor and it'll show a green{' '}
|
||||||
|
<span className="font-bold text-green-400">Verified</span> badge on both.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,33 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Users, GraduationCap, ChevronRight, Link2, BadgeCheck } from 'lucide-react';
|
||||||
import { Users, GraduationCap, ChevronRight, Link2 } from 'lucide-react';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useCrew } from '@/hooks/useCrews';
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { parseAddrCoord } from '@/lib/types';
|
import { parseAddrCoord } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small "✓ Verified" pill shown when a link is confirmed from BOTH sides
|
||||||
|
* (the crew links the Lab profile AND the Lab profile links the crew). A
|
||||||
|
* one-sided claim shows nothing — we never imply trust that isn't mutual.
|
||||||
|
*/
|
||||||
|
function MutualBadge() {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-black uppercase leading-none text-green-400">
|
||||||
|
<BadgeCheck className="h-3 w-3" />
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-[200px] text-xs">
|
||||||
|
Both sides confirm this link — the crew and the Lab profile each point at
|
||||||
|
the other.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const LAB_TYPE_LABEL: Record<number, string> = {
|
const LAB_TYPE_LABEL: Record<number, string> = {
|
||||||
35001: 'Coach',
|
35001: 'Coach',
|
||||||
35002: 'Filmer',
|
35002: 'Filmer',
|
||||||
|
|
@ -19,13 +41,19 @@ const LAB_TYPE_LABEL: Record<number, string> = {
|
||||||
35011: 'Musician',
|
35011: 'Musician',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A single linked-crew row, resolved from a `35006:pubkey:d` coordinate. */
|
/**
|
||||||
function CrewRow({ coord }: { coord: string }) {
|
* A single linked-crew row, resolved from a `35006:pubkey:d` coordinate.
|
||||||
|
* `selfCoord` is the coordinate of the Lab profile showing this card — used to
|
||||||
|
* check whether the crew links BACK (mutual = verified).
|
||||||
|
*/
|
||||||
|
function CrewRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
|
||||||
const parsed = parseAddrCoord(coord);
|
const parsed = parseAddrCoord(coord);
|
||||||
const { data: crew } = useCrew(parsed?.identifier ?? '', parsed?.pubkey);
|
const { data: crew } = useCrew(parsed?.identifier ?? '', parsed?.pubkey);
|
||||||
|
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const mutual = !!selfCoord && !!crew?.labRefs?.includes(selfCoord);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/crew/${parsed.pubkey}/${parsed.identifier}`}
|
to={`/crew/${parsed.pubkey}/${parsed.identifier}`}
|
||||||
|
|
@ -39,7 +67,10 @@ function CrewRow({ coord }: { coord: string }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="truncate text-sm font-bold text-white">{crew?.name ?? 'Skate Crew'}</p>
|
<p className="truncate text-sm font-bold text-white">{crew?.name ?? 'Skate Crew'}</p>
|
||||||
|
{mutual && <MutualBadge />}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-teal-400">Crew{crew?.location ? ` · ${crew.location}` : ''}</p>
|
<p className="text-xs text-teal-400">Crew{crew?.location ? ` · ${crew.location}` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
|
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||||
|
|
@ -47,14 +78,20 @@ function CrewRow({ coord }: { coord: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single linked-Lab-profile row, resolved from a `kind:pubkey:d` coordinate. */
|
/**
|
||||||
function LabRow({ coord }: { coord: string }) {
|
* A single linked-Lab-profile row, resolved from a `kind:pubkey:d` coordinate.
|
||||||
|
* `selfCoord` is the coordinate of the crew showing this card — used to check
|
||||||
|
* whether the Lab profile links BACK (mutual = verified).
|
||||||
|
*/
|
||||||
|
function LabRow({ coord, selfCoord }: { coord: string; selfCoord?: string }) {
|
||||||
const parsed = parseAddrCoord(coord);
|
const parsed = parseAddrCoord(coord);
|
||||||
const { data: profile } = useLabProfile(parsed?.identifier ?? '', parsed?.pubkey);
|
const { data: profile } = useLabProfile(parsed?.identifier ?? '', parsed?.pubkey);
|
||||||
|
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
const typeLabel = LAB_TYPE_LABEL[parsed.kind] ?? 'Lab';
|
const typeLabel = LAB_TYPE_LABEL[parsed.kind] ?? 'Lab';
|
||||||
|
|
||||||
|
const mutual = !!selfCoord && profile?.crewRef === selfCoord;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/coach/${parsed.pubkey}/${parsed.identifier}`}
|
to={`/coach/${parsed.pubkey}/${parsed.identifier}`}
|
||||||
|
|
@ -68,7 +105,10 @@ function LabRow({ coord }: { coord: string }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="truncate text-sm font-bold text-white">{profile?.title ?? `${typeLabel} profile`}</p>
|
<p className="truncate text-sm font-bold text-white">{profile?.title ?? `${typeLabel} profile`}</p>
|
||||||
|
{mutual && <MutualBadge />}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-gray-400">In The Lab · {typeLabel}</p>
|
<p className="text-xs text-gray-400">In The Lab · {typeLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
|
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||||
|
|
@ -76,8 +116,12 @@ function LabRow({ coord }: { coord: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Card shown on a Lab profile: the crew it belongs to. */
|
/**
|
||||||
export function LinkedCrewCard({ crewRef }: { crewRef?: string }) {
|
* Card shown on a Lab profile: the crew it belongs to.
|
||||||
|
* `selfCoord` is this Lab profile's own coordinate (`kind:pubkey:d`) so the row
|
||||||
|
* can show a "Verified" badge when the crew links back.
|
||||||
|
*/
|
||||||
|
export function LinkedCrewCard({ crewRef, selfCoord }: { crewRef?: string; selfCoord?: string }) {
|
||||||
if (!crewRef) return null;
|
if (!crewRef) return null;
|
||||||
return (
|
return (
|
||||||
<Card className="bg-gray-900 border-gray-700">
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
|
|
@ -87,14 +131,18 @@ export function LinkedCrewCard({ crewRef }: { crewRef?: string }) {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CrewRow coord={crewRef} />
|
<CrewRow coord={crewRef} selfCoord={selfCoord} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Card shown on a crew: the Lab service profiles it links to. */
|
/**
|
||||||
export function LinkedLabCard({ labRefs }: { labRefs: string[] }) {
|
* Card shown on a crew: the Lab service profiles it links to.
|
||||||
|
* `selfCoord` is the crew's own coordinate (`35006:pubkey:d`) so each row can
|
||||||
|
* show a "Verified" badge when that Lab profile links back to this crew.
|
||||||
|
*/
|
||||||
|
export function LinkedLabCard({ labRefs, selfCoord }: { labRefs: string[]; selfCoord?: string }) {
|
||||||
if (!labRefs || labRefs.length === 0) return null;
|
if (!labRefs || labRefs.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Card className="bg-gray-900 border-gray-700">
|
<Card className="bg-gray-900 border-gray-700">
|
||||||
|
|
@ -106,7 +154,7 @@ export function LinkedLabCard({ labRefs }: { labRefs: string[] }) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{labRefs.map((coord) => (
|
{labRefs.map((coord) => (
|
||||||
<LabRow key={coord} coord={coord} />
|
<LabRow key={coord} coord={coord} selfCoord={selfCoord} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ const TYPE_LABEL: Record<string, string> = {
|
||||||
editor: 'Editor',
|
editor: 'Editor',
|
||||||
photographer: 'Photographer',
|
photographer: 'Photographer',
|
||||||
builder: 'Builder',
|
builder: 'Builder',
|
||||||
|
artist: 'Artist',
|
||||||
|
graffiti: 'Graffiti',
|
||||||
|
tattoo: 'Tattoo',
|
||||||
|
trainer: 'Trainer',
|
||||||
|
musician: 'Musician',
|
||||||
|
judge: 'Judge',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LabLinkFieldProps {
|
interface LabLinkFieldProps {
|
||||||
|
|
@ -52,6 +58,8 @@ export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProp
|
||||||
</Label>
|
</Label>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional.
|
Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional.
|
||||||
|
When the Lab profile also links back to this crew, both show a green{' '}
|
||||||
|
<span className="font-bold text-green-400">Verified</span> badge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -619,8 +619,12 @@ export default function CoachDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linked crew (cross-link) */}
|
{/* Linked crew (cross-link) — pass this profile's own coord so a
|
||||||
<LinkedCrewCard crewRef={profile.crewRef} />
|
mutual link shows a "Verified" badge. */}
|
||||||
|
<LinkedCrewCard
|
||||||
|
crewRef={profile.crewRef}
|
||||||
|
selfCoord={`${profileKind}:${profile.pubkey}:${profile.identifier}`}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Location Card */}
|
{/* Location Card */}
|
||||||
{profile.location && (
|
{profile.location && (
|
||||||
|
|
|
||||||
|
|
@ -512,8 +512,9 @@ export default function CrewDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linked Lab profiles — hire this crew */}
|
{/* Linked Lab profiles — hire this crew. Pass the crew's own coord
|
||||||
<LinkedLabCard labRefs={crew.labRefs} />
|
so each row shows a "Verified" badge when the Lab profile links back. */}
|
||||||
|
<LinkedLabCard labRefs={crew.labRefs} selfCoord={crewCoord} />
|
||||||
|
|
||||||
{/* Home spots */}
|
{/* Home spots */}
|
||||||
{crew.spotRefs.length > 0 && (
|
{crew.spotRefs.length > 0 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue