diff --git a/NIP.md b/NIP.md index 666bedf..bd13257 100644 --- a/NIP.md +++ b/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", "", "", "admin"], ["p", ""], ["a", "30818::"], @@ -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: ` | 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-` | 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. | diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index fc411f2..1bee7d3 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -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 = { + 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(existing?.focus ?? 'mixed'); const [founded, setFounded] = useState(existing?.founded ?? ''); const [image, setImage] = useState(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) { + {/* Lab scene focus — cross-links the crew into The Lab */} +
+
+ +

+ Is your crew focused on a craft? Pick one to also show up in that scene in{' '} + The Lab. Regular skate crews stay{' '} + Mixed. +

+
+
+ {FOCUS_ORDER.map((key) => { + const Icon = FOCUS_ICONS[key]; + const selected = focus === key; + return ( + + ); + })} +
+
+ {/* Members */}
diff --git a/src/components/LabCrewsStrip.tsx b/src/components/LabCrewsStrip.tsx new file mode 100644 index 0000000..e434deb --- /dev/null +++ b/src/components/LabCrewsStrip.tsx @@ -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 ( + + + + + {crew.name.slice(0, 2).toUpperCase()} + + +
+

{crew.name}

+ {crew.location ? ( +

+ + {crew.location} +

+ ) : ( +

+ + {crew.members.length} {crew.members.length === 1 ? 'member' : 'members'} +

+ )} +
+ + + ); +} + +/** + * 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 ( +
+
+

+ + Crews in this scene + + {CREW_FOCUS_LABELS[focus]} + +

+ + All crews + +
+ +
+ {isLoading + ? Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ )) + : crews?.map((crew) => )} +
+
+ ); +} diff --git a/src/components/wallet/CashuWalletCard.tsx b/src/components/wallet/CashuWalletCard.tsx index 01869c6..64f8255 100644 --- a/src/components/wallet/CashuWalletCard.tsx +++ b/src/components/wallet/CashuWalletCard.tsx @@ -62,37 +62,72 @@ export function CashuWalletCard() { function CashuWalletBrowserNotice() { return ( - - - - - Built-in Ecash Wallet - - - The built-in Cashu wallet runs in the SkateSpots app - + + {/* Ambient glow */} +
+
+ + +
+
+ +
+
+ + Built-in Ecash Wallet + + App only + + + + Self-custodial Cashu ecash, powered by Bitcoin Lightning + +
+
- -
- -
-

Open SkateSpots in the app to use ecash

-

- 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 - zaps — works right here in your browser. -

+ + + {/* Native-only explainer — framed as a premium capability, not a disabled feature */} +
+
+
+ +
+
+

Unlock the full wallet in the SkateSpots app

+

+ 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. +

+
-
-

- Zap & tip in the browser + {/* What works right here — the actual money flow */} +

+

+ + + + Zap & tip — works in your browser now

-

- 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. +

+ 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.

+
+ {['Nostr Wallet Connect', 'Alby / WebLN', 'Lightning address'].map((label) => ( + + + {label} + + ))} +
diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index 615a815..b54dcab 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -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, 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(); diff --git a/src/lib/types.ts b/src/lib/types.ts index 82f4d55..b7781d6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -882,6 +882,58 @@ export const CREW_VIBE_LABELS: Record = { 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 = { + mixed: 'Mixed / General', + builder: 'DIY & Builders', + photo: 'Photo & Film', + coaching: 'Coaching', +}; + +export const CREW_FOCUS_DESCRIPTIONS: Record = { + 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, 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 = { + '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 { + 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, diff --git a/src/pages/CrewsPage.tsx b/src/pages/CrewsPage.tsx index c682c60..a16d787 100644 --- a/src/pages/CrewsPage.tsx +++ b/src/pages/CrewsPage.tsx @@ -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 = { + mixed: Sparkles, + builder: HardHat, + photo: Camera, + coaching: GraduationCap, +}; function CrewCard({ crew }: { crew: SkateCrew }) { return ( @@ -30,6 +45,15 @@ function CrewCard({ crew }: { crew: SkateCrew }) { {crew.members.length} + {crew.focus !== 'mixed' && (() => { + const FocusIcon = FOCUS_BADGE_ICONS[crew.focus]; + return ( + + + {CREW_FOCUS_LABELS[crew.focus]} + + ); + })()} {crew.location && (

@@ -58,18 +82,21 @@ export default function CrewsPage() { const { data: crews, isLoading } = useCrews(200); const [query, setQuery] = useState(''); + const [focusFilter, setFocusFilter] = useState('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 ( @@ -108,6 +135,29 @@ export default function CrewsPage() { )}

+ {/* Focus / type filter */} +
+ {FOCUS_FILTERS.map(({ key, label, icon: Icon }) => { + const active = focusFilter === key; + return ( + + ); + })} +
+ {/* List */} {isLoading ? (
diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 71079e9..62d7ccb 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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() { )}
+ + {/* ── Crews in this scene ── */} +
{/* ── Filter drawer ── */}