Polish native-only wallet card + add Crew↔Lab scene focus

Wallet (Request A): redesign the browser CashuWalletBrowserNotice into a
premium, intentional card — gradient hero header, "App only" badge, and a
"works in your browser now" panel highlighting NWC / Alby / Lightning
address zaps. Keeps the native-only gate; no bug reintroduced.

Crew ↔ Lab interconnection: crews can now declare an optional Lab scene
focus that surfaces them inside the matching Lab category, while regular
crews stay "Mixed" and only appear on the Crews page.
- types: CrewFocus ('mixed'|'builder'|'photo'|'coaching'), labels,
  crew-focus-* t-tag scheme, serviceType→focus mapping; parseSkateCrew
  extracts focus and excludes focus tags from vibes; SkateCrew.focus field.
- CrewForm: "Crew Type" selector (Mixed / DIY & Builders / Photo & Film /
  Coaching); publishes a queryable crew-focus-* t-tag when not mixed.
- useCrews: new useCrewsByFocus hook (relay-level #t filtering).
- CrewsPage: focus badge on cards + focus filter chips.
- LabPage: "Crews in this scene" horizontal strip beneath results, mapping
  building→builder, filming/photography/editing→photo, coaching→coaching.
- NIP.md: document the crew-focus-<scene> tag.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 18:08:51 +07:00
parent 5cde2a640d
commit 9a8609354e
8 changed files with 378 additions and 36 deletions

2
NIP.md
View file

@ -1716,6 +1716,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
["founded", "2019"],
["t", "street"],
["t", "transition"],
["t", "crew-focus-photo"],
["p", "<memberPubkey1>", "", "admin"],
["p", "<memberPubkey2>"],
["a", "30818:<spotFounder>:<spotDtag>"],
@ -1744,6 +1745,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
| `t: crew-focus-<scene>` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — covers filming, photography & editing), or `crew-focus-coaching`. Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |

View file

@ -21,11 +21,23 @@ import {
SKATE_CREW_KIND,
SKATE_SPOT_KIND,
CREW_VIBE_LABELS,
CREW_FOCUS_LABELS,
CREW_FOCUS_DESCRIPTIONS,
CREW_FOCUS_TAG,
isValidNoffer,
type SkateCrew,
type CrewFocus,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign } from 'lucide-react';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles } from 'lucide-react';
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
mixed: Sparkles,
builder: HardHat,
photo: Camera,
coaching: GraduationCap,
};
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
@ -110,6 +122,7 @@ export function CrewForm({ existing }: CrewFormProps) {
(existing?.vibes ?? []).filter((v) => !presetKeys.has(v))
);
const [customVibeInput, setCustomVibeInput] = useState('');
const [focus, setFocus] = useState<CrewFocus>(existing?.focus ?? 'mixed');
const [founded, setFounded] = useState(existing?.founded ?? '');
const [image, setImage] = useState<string | null>(existing?.image ?? null);
const [website, setWebsite] = useState(existing?.website ?? '');
@ -266,6 +279,8 @@ export function CrewForm({ existing }: CrewFormProps) {
for (const v of vibes) tags.push(['t', v]);
for (const v of customVibes) tags.push(['t', v]);
// Lab scene focus — only tagged when not the default "mixed" crew.
if (focus !== 'mixed') tags.push(['t', CREW_FOCUS_TAG[focus]]);
if (founded.trim()) tags.push(['founded', founded.trim()]);
if (image) tags.push(['image', image]);
if (website.trim()) tags.push(['website', website.trim()]);
@ -388,6 +403,50 @@ export function CrewForm({ existing }: CrewFormProps) {
</div>
</div>
{/* Lab scene focus — cross-links the crew into The Lab */}
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400">
<Sparkles className="h-4 w-4" /> Crew Type
</Label>
<p className="mt-1 text-xs text-gray-500">
Is your crew focused on a craft? Pick one to also show up in that scene in{' '}
<strong className="text-gray-300">The Lab</strong>. Regular skate crews stay{' '}
<strong className="text-gray-300">Mixed</strong>.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{FOCUS_ORDER.map((key) => {
const Icon = FOCUS_ICONS[key];
const selected = focus === key;
return (
<button
key={key}
type="button"
onClick={() => setFocus(key)}
aria-pressed={selected}
className={cn(
'flex items-start gap-2.5 rounded-xl border p-3 text-left transition-all active:scale-[0.98]',
selected
? 'border-teal-500 bg-teal-500/15 ring-1 ring-teal-500/40'
: 'border-gray-700 bg-gray-800 hover:bg-gray-700/70'
)}
>
<Icon className={cn('mt-0.5 h-4 w-4 flex-shrink-0', selected ? 'text-teal-300' : 'text-gray-400')} />
<span className="min-w-0">
<span className={cn('block text-sm font-bold leading-tight', selected ? 'text-teal-200' : 'text-gray-200')}>
{CREW_FOCUS_LABELS[key]}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-gray-500">
{CREW_FOCUS_DESCRIPTIONS[key]}
</span>
</span>
</button>
);
})}
</div>
</div>
{/* Members */}
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>

View file

@ -0,0 +1,86 @@
import { Link } from 'react-router-dom';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Users, ChevronRight, MapPin } from 'lucide-react';
import { useCrewsByFocus } from '@/hooks/useCrews';
import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew } from '@/lib/types';
import { Skeleton } from '@/components/ui/skeleton';
type ServiceType = 'coaching' | 'filming' | 'editing' | 'photography' | 'building';
function CrewChip({ crew }: { crew: SkateCrew }) {
return (
<Link
to={`/crew/${crew.pubkey}/${crew.identifier}`}
className="group flex w-56 flex-shrink-0 items-center gap-3 rounded-2xl border border-teal-500/20 bg-white/[0.03] p-3 transition-all hover:border-teal-500/50 hover:bg-white/[0.06] active:scale-[0.99]"
>
<Avatar className="h-11 w-11 rounded-xl border border-teal-500/30">
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-xl bg-teal-900/40 text-teal-300 font-black text-sm">
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-black text-white leading-tight">{crew.name}</p>
{crew.location ? (
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-gray-400">
<MapPin className="h-3 w-3 flex-shrink-0 text-teal-400" />
{crew.location}
</p>
) : (
<p className="mt-0.5 flex items-center gap-1 text-xs text-gray-500">
<Users className="h-3 w-3 text-teal-400" />
{crew.members.length} {crew.members.length === 1 ? 'member' : 'members'}
</p>
)}
</div>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-gray-600 group-hover:text-teal-400" />
</Link>
);
}
/**
* A horizontal strip of crews that share the active Lab scene's focus.
* Shown beneath the Lab results so crews and individual service providers
* cross-reference each other. Renders nothing when there are no matching crews.
*/
export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
const focus = crewFocusForServiceType(serviceType);
const { data: crews, isLoading } = useCrewsByFocus(focus, 20);
// Only render the section when there's something to show (or while loading).
if (!isLoading && (!crews || crews.length === 0)) return null;
return (
<section className="mt-8">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-teal-400">
<Users className="h-4 w-4" />
Crews in this scene
<span className="rounded-full bg-teal-500/15 px-2 py-0.5 text-[10px] font-bold text-teal-300">
{CREW_FOCUS_LABELS[focus]}
</span>
</h2>
<Link
to="/crews"
className="flex items-center gap-0.5 text-xs font-bold text-gray-400 hover:text-teal-300"
>
All crews <ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="-mx-4 flex gap-3 overflow-x-auto px-4 pb-2 scroll-container">
{isLoading
? Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex w-56 flex-shrink-0 items-center gap-3 rounded-2xl border border-white/5 p-3">
<Skeleton className="h-11 w-11 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))
: crews?.map((crew) => <CrewChip key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />)}
</div>
</section>
);
}

View file

@ -62,37 +62,72 @@ export function CashuWalletCard() {
function CashuWalletBrowserNotice() {
return (
<Card className="border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-yellow-500/5">
<CardHeader className="p-4">
<CardTitle className="text-base font-bold flex items-center gap-2">
<Wallet className="h-5 w-5 text-orange-500" />
Built-in Ecash Wallet
</CardTitle>
<CardDescription className="text-xs">
The built-in Cashu wallet runs in the SkateSpots app
</CardDescription>
<Card className="relative overflow-hidden border-orange-500/30 bg-gradient-to-br from-orange-500/[0.07] via-background to-yellow-500/[0.05]">
{/* Ambient glow */}
<div className="pointer-events-none absolute -top-16 -right-16 h-40 w-40 rounded-full bg-orange-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -left-10 h-40 w-40 rounded-full bg-yellow-500/[0.07] blur-3xl" />
<CardHeader className="relative p-5 pb-3">
<div className="flex items-center gap-3">
<div className="relative flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-orange-500 to-amber-500 shadow-lg shadow-orange-500/25">
<Nut className="h-5 w-5 text-black" />
</div>
<div className="min-w-0">
<CardTitle className="text-base font-black tracking-tight flex items-center gap-2">
Built-in Ecash Wallet
<Badge className="border-0 bg-orange-500/15 text-[10px] font-bold uppercase tracking-wider text-orange-400">
App only
</Badge>
</CardTitle>
<CardDescription className="text-xs mt-0.5">
Self-custodial Cashu ecash, powered by Bitcoin Lightning
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
<div className="flex items-start gap-3 rounded-xl bg-orange-500/10 border border-orange-500/20 p-3">
<Smartphone className="h-5 w-5 text-orange-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-semibold">Open SkateSpots in the app to use ecash</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Web browsers block most Cashu mints for security reasons, so the built-in ecash
wallet is available in the native app. Everything else — sending and receiving
<strong className="text-foreground"> zaps</strong> — works right here in your browser.
</p>
<CardContent className="relative space-y-4 p-5 pt-1">
{/* Native-only explainer — framed as a premium capability, not a disabled feature */}
<div className="rounded-2xl border border-orange-500/20 bg-gradient-to-br from-orange-500/[0.08] to-transparent p-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/15">
<Smartphone className="h-4 w-4 text-orange-400" />
</div>
<div className="space-y-1.5">
<p className="text-sm font-bold leading-tight">Unlock the full wallet in the SkateSpots app</p>
<p className="text-xs text-muted-foreground leading-relaxed">
The built-in ecash wallet lets you mint, send and redeem Cashu tokens right at the
spot. It lives in the native app, where it can talk to mints directly — browsers
sandbox those connections for your security.
</p>
</div>
</div>
</div>
<div className="rounded-xl border bg-muted/40 p-3 space-y-2">
<p className="text-sm font-semibold flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-500" /> Zap &amp; tip in the browser
{/* What works right here — the actual money flow */}
<div className="rounded-2xl border bg-muted/30 p-4">
<p className="text-sm font-bold flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-lg bg-amber-500/15">
<Zap className="h-3.5 w-3.5 text-amber-500" />
</span>
Zap &amp; tip — works in your browser now
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Connect a Lightning wallet below (Nostr Wallet Connect or a browser extension like
Alby) to zap spots, events, coaches, and crews. No Cashu mint required.
<p className="mt-2 text-xs text-muted-foreground leading-relaxed">
Sending sats to spots, events, coaches and crews works right here. Just connect a
Lightning wallet below — Nostr Wallet Connect or a browser extension like Alby. No
Cashu mint needed.
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{['Nostr Wallet Connect', 'Alby / WebLN', 'Lightning address'].map((label) => (
<span
key={label}
className="inline-flex items-center gap-1 rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-[11px] font-semibold text-amber-500/90"
>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
{label}
</span>
))}
</div>
</div>
</CardContent>
</Card>

View file

@ -1,5 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_CREW_KIND, parseSkateCrew, type SkateCrew } from '@/lib/types';
import {
SKATE_CREW_KIND,
parseSkateCrew,
CREW_FOCUS_TAG,
type SkateCrew,
type CrewFocus,
} from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export function useCrews(limit = 200) {
@ -35,6 +41,46 @@ export function useCrews(limit = 200) {
});
}
/**
* Query crews that belong to a specific Lab scene (builder / photo / coaching).
* Filters at the relay level via the crew-focus `t` tag, so The Lab pages can
* show a "Crews in this scene" strip without scanning every crew.
*/
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const focusTag = CREW_FOCUS_TAG[focus];
return useQuery({
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[
{
kinds: [SKATE_CREW_KIND],
'#t': [focusTag],
limit,
},
],
{ signal }
);
const crews: SkateCrew[] = [];
for (const event of events) {
const crew = parseSkateCrew(event);
// Double-check focus after parsing (guards against loose relay matches).
if (crew && crew.focus === focus) crews.push(crew);
}
return crews.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
export function useCrew(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();

View file

@ -882,6 +882,58 @@ export const CREW_VIBE_LABELS: Record<string, string> = {
adaptive: 'Adaptive',
};
/**
* A crew's "Lab scene" — an optional craft focus that surfaces the crew inside
* the matching category of The Lab. A crew without a focus (`mixed`) is a
* regular skate crew and only appears on the Crews page.
*
* These three scenes map onto the five Lab service types:
* builder → building
* photo → filming + photography + editing
* coaching → coaching
*
* Stored on the crew event as a single-letter, relay-queryable `t` tag using
* the `CREW_FOCUS_TAG` map (e.g. `["t", "crew-focus-builder"]`).
*/
export type CrewFocus = 'mixed' | 'builder' | 'photo' | 'coaching';
export const CREW_FOCUS_LABELS: Record<CrewFocus, string> = {
mixed: 'Mixed / General',
builder: 'DIY & Builders',
photo: 'Photo & Film',
coaching: 'Coaching',
};
export const CREW_FOCUS_DESCRIPTIONS: Record<CrewFocus, string> = {
mixed: 'A regular skate crew — shows on the Crews page only.',
builder: 'A DIY / park-building crew — also shows in The Lab under Builders.',
photo: 'A filming, photography or editing crew — also shows in The Lab under Photo & Film.',
coaching: 'A coaching / lessons crew — also shows in The Lab under Coaching.',
};
/** `t`-tag value stored on the crew event for each non-mixed focus. */
export const CREW_FOCUS_TAG: Record<Exclude<CrewFocus, 'mixed'>, string> = {
builder: 'crew-focus-builder',
photo: 'crew-focus-photo',
coaching: 'crew-focus-coaching',
};
/** Reverse lookup: `t`-tag value → CrewFocus. */
export const CREW_FOCUS_FROM_TAG: Record<string, CrewFocus> = {
'crew-focus-builder': 'builder',
'crew-focus-photo': 'photo',
'crew-focus-coaching': 'coaching',
};
/** Map a Lab service type to the crew focus that belongs in that scene. */
export function crewFocusForServiceType(
serviceType: 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
): Exclude<CrewFocus, 'mixed'> {
if (serviceType === 'building') return 'builder';
if (serviceType === 'coaching') return 'coaching';
return 'photo'; // filming, editing, photography
}
export interface SkateCrew {
id: string;
pubkey: string;
@ -892,6 +944,7 @@ export interface SkateCrew {
location?: string;
image?: string; // Crew logo / photo
vibes: string[]; // Style/focus tags
focus: CrewFocus; // Optional Lab scene (builder/photo/coaching) or 'mixed'
founded?: string; // Year founded (cosmetic)
website?: string;
instagram?: string;
@ -1415,7 +1468,13 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const image = event.tags.find(([name]) => name === 'image')?.[1];
const vibes = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecrew').map(([, value]) => value);
// Vibes are `t` tags that aren't the crew marker or a crew-focus scene tag.
const vibes = event.tags
.filter(([name, value]) => name === 't' && value !== 'skatecrew' && !CREW_FOCUS_FROM_TAG[value])
.map(([, value]) => value);
// Lab scene focus — first matching crew-focus `t` tag wins, else 'mixed'.
const focusTag = event.tags.find(([name, value]) => name === 't' && !!CREW_FOCUS_FROM_TAG[value])?.[1];
const focus: CrewFocus = focusTag ? CREW_FOCUS_FROM_TAG[focusTag] : 'mixed';
const founded = event.tags.find(([name]) => name === 'founded')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
@ -1444,6 +1503,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
geohash,
image,
vibes,
focus,
founded,
website,
instagram,

View file

@ -7,9 +7,24 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCrews } from '@/hooks/useCrews';
import { CREW_VIBE_LABELS, type SkateCrew } from '@/lib/types';
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Users, Plus, Search, MapPin, X } from 'lucide-react';
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles } from 'lucide-react';
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
{ key: 'all', label: 'All', icon: Users },
{ key: 'builder', label: CREW_FOCUS_LABELS.builder, icon: HardHat },
{ key: 'photo', label: CREW_FOCUS_LABELS.photo, icon: Camera },
{ key: 'coaching', label: CREW_FOCUS_LABELS.coaching, icon: GraduationCap },
{ key: 'mixed', label: CREW_FOCUS_LABELS.mixed, icon: Sparkles },
];
const FOCUS_BADGE_ICONS: Record<CrewFocus, typeof Users> = {
mixed: Sparkles,
builder: HardHat,
photo: Camera,
coaching: GraduationCap,
};
function CrewCard({ crew }: { crew: SkateCrew }) {
return (
@ -30,6 +45,15 @@ function CrewCard({ crew }: { crew: SkateCrew }) {
<Users className="h-2.5 w-2.5" /> {crew.members.length}
</span>
</h3>
{crew.focus !== 'mixed' && (() => {
const FocusIcon = FOCUS_BADGE_ICONS[crew.focus];
return (
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-teal-300 border border-teal-500/25">
<FocusIcon className="h-2.5 w-2.5" />
{CREW_FOCUS_LABELS[crew.focus]}
</span>
);
})()}
{crew.location && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 truncate">
<MapPin className="h-3 w-3 text-teal-400 flex-shrink-0" />
@ -58,18 +82,21 @@ export default function CrewsPage() {
const { data: crews, isLoading } = useCrews(200);
const [query, setQuery] = useState('');
const [focusFilter, setFocusFilter] = useState<CrewFocus | 'all'>('all');
const filtered = useMemo(() => {
if (!crews) return [];
if (!query.trim()) return crews;
const q = query.toLowerCase();
return crews.filter(
(c) =>
const q = query.trim().toLowerCase();
return crews.filter((c) => {
if (focusFilter !== 'all' && c.focus !== focusFilter) return false;
if (!q) return true;
return (
c.name.toLowerCase().includes(q) ||
c.location?.toLowerCase().includes(q) ||
c.bio?.toLowerCase().includes(q)
);
}, [crews, query]);
);
});
}, [crews, query, focusFilter]);
return (
<Layout>
@ -108,6 +135,29 @@ export default function CrewsPage() {
)}
</div>
{/* Focus / type filter */}
<div className="mb-5 flex flex-wrap gap-2">
{FOCUS_FILTERS.map(({ key, label, icon: Icon }) => {
const active = focusFilter === key;
return (
<button
key={key}
type="button"
onClick={() => setFocusFilter(key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
active
? 'border-teal-500 bg-teal-500 text-black'
: 'border-white/10 bg-white/5 text-gray-300 hover:border-teal-500/40 hover:text-teal-300'
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
})}
</div>
{/* List */}
{isLoading ? (
<div className="space-y-3">

View file

@ -50,6 +50,7 @@ import {
import { cn } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ProBadge } from '@/components/ProBadge';
import { LabCrewsStrip } from '@/components/LabCrewsStrip';
import { isFeaturedActive } from '@/lib/types';
// ─── Constants ────────────────────────────────────────────────────────────────
@ -1071,6 +1072,9 @@ export default function LabPage() {
<EmptyState serviceType={serviceType} user={!!user} />
)}
</div>
{/* ── Crews in this scene ── */}
<LabCrewsStrip serviceType={serviceType} />
</div>
{/* ── Filter drawer ── */}