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:
parent
5cde2a640d
commit
9a8609354e
8 changed files with 378 additions and 36 deletions
2
NIP.md
2
NIP.md
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
86
src/components/LabCrewsStrip.tsx
Normal file
86
src/components/LabCrewsStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ── */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue