Add Photographer card to The Lab (kind 35004) + improve Filmer section

New Photographers category (blue-indigo) — separate from Filmers:
- New addressable kind 35004 + SkatePhotographer type + parseSkatePhotographer.
- Rich photographer taxonomy as queryable `t` tags: services (action,
  sequence, editorial, product, events…), camera bodies, lenses, lighting
  (Profoto/Elinchrom/Godox/triggers…), support/rig, and shooting style,
  plus a freeform "gear" tag and a portfolio link.
- BecomePhotographerPage form with chip-grid selectors for all gear
  categories + "what's in the bag" textarea, wired to /become-photographer.
- The Lab: 4th "Photo" tab (indigo accent) with its own filters, cards,
  empty state; OfferFAB gains "Offer Photography"; useLabProfiles fetches
  35004; CoachDetailPage renders photographers (and editors) properly with
  per-type accent, gear + portfolio display.
- NIP.md: documented the whole Lab (kinds 35001-35004) incl. photographer.

Filmer improvements (now video-focused, photography split out):
- Removed the token "Action Photography" service; added follow/line filming.
- Richer equipment list: cinema camera, HVX, action cam, gimbal, 4K/HFR.
- Filmers gain a freeform "what's in the bag" gear field + a reel/portfolio
  link, both surfaced on the detail page.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 11:27:22 +07:00
parent 88a36e52ae
commit adeab62aa4
8 changed files with 999 additions and 36 deletions

108
NIP.md
View file

@ -1402,3 +1402,111 @@ The value of a badge is proportional to the reputation of the witness:
"sig": "..."
}
```
## The Lab — Service Profiles
The Lab connects skaters with service providers. Each provider type is an
addressable event (kind 35001–35004) identified by `pubkey + kind + d-tag`.
They share a common base schema and are distinguished by kind and a self-tag.
| Kind | Provider | Self-tag |
|------|----------|----------|
| 35001 | Coach | `skatecoach` |
| 35002 | Filmer (video) | `skatefilmer` |
| 35003 | Video Editor | `skateeditor` |
| 35004 | Photographer | `skatephotographer` |
### Common Base Schema
```json
{
"kind": 35004,
"content": "<bio / about the provider>",
"tags": [
["d", "<profile-identifier>"],
["title", "<display name>"],
["t", "<self-tag>"],
["t", "<service/skill/gear tag>"],
["location", "<city / area>"],
["g", "<geohash>"],
["image", "<profile image url>"],
["experience", "<years>"],
["rate", "<freeform rate, e.g. 20000 sats/shoot>"],
["phone", "<phone>"],
["email", "<email>"],
["website", "<url>"],
["a", "30818:<pubkey>:<d-tag>"],
["alt", "<human-readable description>"]
]
}
```
#### Required Tags
| Tag | Description |
|-----|-------------|
| `d` | Unique profile identifier (e.g. `photography-profile`) |
| `title` | Display name |
| `t` | Must include the provider self-tag (see table above) |
#### Shared Optional Tags
`location`, `g` (geohash), `image`, `experience`, `rate`, `phone`, `email`,
`website`, `a` (spot references to kind 30818), `alt`.
### Photographer (kind 35004)
Skate photography is a distinct craft from filming — it is shot-, light-, and
print-oriented. A photographer profile records services and gear as queryable
`t` tags, plus a freeform `gear` tag and a `portfolio`/`social` link.
Categories encoded as `t` tags:
- **Services**: `action`, `sequence`, `portraits`, `editorial`, `product`, `events`, `lifestyle`, `prints`
- **Camera bodies**: `dslr`, `mirrorless`, `medium`, `film35`, `filmmedium`, `point`, `phone`
- **Lenses**: `fisheye`, `wide`, `standard`, `telephoto`, `macro`, `zoom`
- **Lighting**: `strobes`, `speedlights`, `profoto`, `elinchrom`, `godox`, `triggers`, `continuous`, `reflectors`, `naturallight`
- **Support**: `tripod`, `monopod`, `lightstands`, `gimbal`, `bag`, `laptop`
- **Style**: `flashfisheye`, `natural`, `bw`, `film`, `longlens`, `studio`, `street`
Photographer-specific optional tags:
| Tag | Description |
|-----|-------------|
| `gear` | Freeform "what's in the bag" (exact bodies, lenses, lights) |
| `portfolio` | Portfolio link (Instagram, personal site, etc.) |
| `social` | Additional social handle or link |
#### Example: Photographer
```json
{
"kind": 35004,
"content": "Shooting street and park sessions for 8 years. Love a lit flash fisheye at dusk.",
"tags": [
["d", "photography-profile"],
["title", "Ana Flash"],
["t", "skatephotographer"],
["t", "action"], ["t", "sequence"], ["t", "editorial"],
["t", "mirrorless"], ["t", "fisheye"], ["t", "telephoto"],
["t", "godox"], ["t", "triggers"],
["t", "tripod"], ["t", "lightstands"],
["t", "flashfisheye"], ["t", "bw"],
["location", "Barcelona, ES"],
["g", "sp3e9"],
["experience", "8"],
["rate", "25000 sats/shoot"],
["gear", "Sony A7 IV, 15mm fisheye, 70-200mm f/2.8, 2× Godox AD200 Pro, remote triggers, Manfrotto tripod"],
["portfolio", "https://instagram.com/anaflash"],
["alt", "Skate photographer: Ana Flash"]
]
}
```
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer"],
"limit": 100
}
```

View file

@ -29,6 +29,7 @@ const PyramidPage = lazy(() => import("./pages/PyramidPage"));
const BecomeCoachPage = lazy(() => import("./pages/BecomeCoachPage"));
const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage"));
const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage"));
const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage"));
const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage"));
const TourDetailPage = lazy(() => import("./pages/TourDetailPage"));
const AboutPage = lazy(() => import("./pages/AboutPage"));
@ -80,6 +81,7 @@ export function AppRouter() {
<Route path="/become-coach" element={<BecomeCoachPage />} />
<Route path="/become-filmer" element={<BecomeFilmerPage />} />
<Route path="/become-editor" element={<BecomeEditorPage />} />
<Route path="/become-photographer" element={<BecomePhotographerPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} />

View file

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, type SkateCoach, type SkateFilmer, type SkateEditor } from '@/lib/types';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor) & {
profileType: 'coach' | 'filmer' | 'editor';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer) & {
profileType: 'coach' | 'filmer' | 'editor' | 'photographer';
};
export function useLabProfiles(limit = 100) {
@ -19,8 +19,8 @@ export function useLabProfiles(limit = 100) {
const events = await querySkateContent(
[
{
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND],
'#t': ['skatecoach', 'skatefilmer', 'skateeditor'],
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND],
'#t': ['skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer'],
limit: limit * 2,
},
],
@ -46,10 +46,15 @@ export function useLabProfiles(limit = 100) {
if (editor) {
profiles.push({ ...editor, profileType: 'editor' });
}
} else if (event.kind === SKATE_PHOTOGRAPHER_KIND) {
const photographer = parseSkatePhotographer(event);
if (photographer) {
profiles.push({ ...photographer, profileType: 'photographer' });
}
}
}
console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors)`);
console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors + photographers)`);
return profiles.sort((a, b) => b.createdAt - a.createdAt);
},
@ -72,7 +77,7 @@ export function useLabProfile(identifier: string, pubkey?: string) {
authors?: string[];
limit: number;
} = {
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND],
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND],
'#d': [identifier],
limit: 1,
};
@ -101,6 +106,9 @@ export function useLabProfile(identifier: string, pubkey?: string) {
} else if (event.kind === SKATE_EDITOR_KIND) {
const editor = parseSkateEditor(event);
return editor ? { ...editor, profileType: 'editor' as const } : null;
} else if (event.kind === SKATE_PHOTOGRAPHER_KIND) {
const photographer = parseSkatePhotographer(event);
return photographer ? { ...photographer, profileType: 'photographer' as const } : null;
}
return null;

View file

@ -46,8 +46,9 @@ export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
export const CALENDAR_EVENT_KIND = 31923;
export const SKATE_COACH_KIND = 35001;
export const SKATE_FILMER_KIND = 35002; // Skate filmer/photographer profiles
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer profiles
export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
@ -473,7 +474,8 @@ export const FILMING_SERVICE_LABELS: Record<string, string> = {
documentaries: 'Documentaries',
contests: 'Filming for Contests',
raw: 'Raw Footage',
photos: 'Action Photography',
followlines: 'Follow / Line Filming',
lines: 'Line & B-Roll Filming',
};
export const FILMING_SERVICE_DESCRIPTIONS: Record<string, string> = {
@ -483,28 +485,39 @@ export const FILMING_SERVICE_DESCRIPTIONS: Record<string, string> = {
documentaries: 'Skate culture and story-driven content',
contests: 'Professional contest and competition coverage',
raw: 'Unedited multi-angle footage',
photos: 'Sequence shots and peak moments',
followlines: 'Fisheye follow-cam for lines',
lines: 'Long-lens lines and cinematic b-roll',
};
// Filming skills/equipment
// Filming skills/equipment (video-focused)
export const FILMING_SKILL_LABELS: Record<string, string> = {
vx1000: 'VX1000',
dslr: 'DSLR/Mirrorless',
hvx: 'HVX / HPX',
cinema: 'Cinema Camera',
dslr: 'DSLR / Mirrorless',
actioncam: 'GoPro / Action Cam',
phone: 'Phone Filming',
drone: 'Drone/Aerial',
drone: 'Drone / Aerial',
gimbal: 'Gimbal / Stabilizer',
fisheye: 'Fisheye Lens',
longlens: 'Long Lens',
'4k': '4K / High Frame Rate',
editing: 'Video Editing',
color: 'Color Grading',
};
export const FILMING_SKILL_DESCRIPTIONS: Record<string, string> = {
vx1000: 'Classic VX1000 skateboarding aesthetic',
hvx: 'Panasonic HVX/HPX standard-def / HD look',
cinema: 'Cinema camera (FX3, C-series, RED, BMPCC)',
dslr: 'High quality DSLR or mirrorless camera',
actioncam: 'GoPro / action cam POV and mounts',
phone: 'Mobile filming with modern smartphones',
drone: 'Aerial and drone footage',
gimbal: 'Stabilized gimbal movement',
fisheye: 'Wide-angle fisheye lens shots',
longlens: 'Long lens for detailed shots',
'4k': '4K resolution and high frame rate slow-mo',
editing: 'Professional video editing and post-production',
color: 'Color correction and grading',
};
@ -555,6 +568,88 @@ export const EDITOR_SKILL_DESCRIPTIONS: Record<string, string> = {
sound: 'Music sync, SFX, audio mastering',
};
// ─── Photographer taxonomy ──────────────────────────────────────────────────
// Skate photography is a distinct craft from filming — it's shot-, light-,
// and print-oriented. These maps power The Lab's Photographers category.
// What a photographer offers (deliverables / shoot types)
export const PHOTO_SERVICE_LABELS: Record<string, string> = {
action: 'Action / Trick Shots',
sequence: 'Sequence Shots',
portraits: 'Portraits',
editorial: 'Editorial / Magazine',
product: 'Product / Shop Photography',
events: 'Event Coverage',
lifestyle: 'Lifestyle / Documentary',
prints: 'Prints & Zines',
};
export const PHOTO_SERVICE_DESCRIPTIONS: Record<string, string> = {
action: 'Peak-action stills of tricks and lines',
sequence: 'Multi-frame trick sequences',
portraits: 'Rider portraits and team shots',
editorial: 'Magazine-ready editorial spreads',
product: 'Deck, gear and shop product photography',
events: 'Comp, jam and premiere coverage',
lifestyle: 'Behind-the-scenes and culture documentary',
prints: 'Physical prints, zines and photo books',
};
// Camera bodies / formats
export const PHOTO_CAMERA_LABELS: Record<string, string> = {
dslr: 'DSLR',
mirrorless: 'Mirrorless',
medium: 'Medium Format',
film35: '35mm Film',
filmmedium: 'Medium Format Film',
point: 'Point & Shoot',
phone: 'Phone',
};
// Lenses
export const PHOTO_LENS_LABELS: Record<string, string> = {
fisheye: 'Fisheye',
wide: 'Wide Angle',
standard: 'Standard / Prime',
telephoto: 'Telephoto / Long Lens',
macro: 'Macro',
zoom: 'Zoom',
};
// Lighting gear
export const PHOTO_LIGHTING_LABELS: Record<string, string> = {
strobes: 'Studio Strobes',
speedlights: 'Speedlights / Flashes',
profoto: 'Profoto',
elinchrom: 'Elinchrom',
godox: 'Godox',
triggers: 'Remote Triggers',
continuous: 'Continuous / LED',
reflectors: 'Reflectors / Diffusers',
naturallight: 'Natural Light Only',
};
// Support / accessories
export const PHOTO_SUPPORT_LABELS: Record<string, string> = {
tripod: 'Tripod',
monopod: 'Monopod',
lightstands: 'Light Stands',
gimbal: 'Gimbal',
bag: 'Mobile Rig / Bag',
laptop: 'On-site Tethering',
};
// Shooting style
export const PHOTO_STYLE_LABELS: Record<string, string> = {
flashfisheye: 'Flash Fisheye',
natural: 'Natural Light',
bw: 'Black & White',
film: 'Film Aesthetic',
longlens: 'Long Lens',
studio: 'Studio / Lit',
street: 'Street / Documentary',
};
export interface SkateCoach {
id: string;
pubkey: string;
@ -591,7 +686,9 @@ export interface SkateFilmer {
phone?: string;
email?: string;
website?: string;
portfolio?: string; // Reel / portfolio link (Vimeo, YouTube, IG)
social?: string; // Social media handle or link
gear?: string; // Freeform "what's in the bag" (cameras, lenses, rigs)
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
createdAt: number;
event: NostrEvent;
@ -620,6 +717,29 @@ export interface SkateEditor {
event: NostrEvent;
}
export interface SkatePhotographer {
id: string;
pubkey: string;
identifier: string;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // Combined services + camera/lens/lighting/support/style tags
experience?: string; // Years of experience
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string; // Portfolio link (Instagram, personal site, etc.)
social?: string;
gear?: string; // Freeform "what's in the bag" (bodies, lenses, lights)
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
createdAt: number;
event: NostrEvent;
}
export interface SkateTour {
id: string;
pubkey: string;
@ -746,7 +866,9 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
const email = event.tags.find(([name]) => name === 'email')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
// Extract spot references (a tags pointing to kind 30818 spots)
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
@ -767,7 +889,9 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
phone,
email,
website,
portfolio,
social,
gear,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
createdAt: event.created_at,
event,
@ -821,6 +945,55 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
};
}
export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | null {
if (event.kind !== SKATE_PHOTOGRAPHER_KIND) return null;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return null;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return 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 skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatephotographer').map(([, value]) => value);
const experience = event.tags.find(([name]) => name === 'experience')?.[1];
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
const email = event.tags.find(([name]) => name === 'email')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
title: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
portfolio,
social,
gear,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
createdAt: event.created_at,
event,
};
}
export function parseSkateTour(event: NostrEvent): SkateTour | null {
if (event.kind !== SKATE_TOUR_KIND) return null;

View file

@ -59,7 +59,9 @@ export default function BecomeFilmerPage() {
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [gear, setGear] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -162,7 +164,9 @@ export default function BecomeFilmerPage() {
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (gear.trim()) tags.push(['gear', gear.trim()]);
// Add spot references
for (const spotId of selectedSpots) {
@ -213,7 +217,7 @@ export default function BecomeFilmerPage() {
<h1 className="text-3xl font-black uppercase neon-lime" style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>Become a Filmer</h1>
<p className="text-gray-400 mt-1">Offer your filming & editing services</p>
<p className="text-gray-400 mt-1">Offer your skate video filming — clips, lines, full parts &amp; contest coverage</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
@ -342,7 +346,7 @@ export default function BecomeFilmerPage() {
</div>
<div className="space-y-3">
<Label>Filming Skills * (select at least one)</Label>
<Label>Cameras & Equipment * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => (
<button
@ -365,6 +369,20 @@ export default function BecomeFilmerPage() {
</div>
</div>
{/* Freeform gear list */}
<div className="space-y-2">
<Label htmlFor="gear">What's in the Bag (optional)</Label>
<Textarea
id="gear"
placeholder="e.g. Sony VX1000 + MK1 fisheye, Panasonic HVX200, DJI RS3 gimbal, GoPro Hero 12, Sennheiser mic..."
value={gear}
onChange={(e) => setGear(e.target.value)}
rows={3}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">List your exact cameras, lenses and rigs — skaters care about the setup.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="experience">Years Experience</Label>
@ -395,10 +413,14 @@ export default function BecomeFilmerPage() {
<Input id="email" type="email" placeholder="filmer@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="website">Website / Portfolio</Label>
<Input id="website" type="url" placeholder="https://myportfolio.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Label htmlFor="portfolio">Reel / Portfolio</Label>
<Input id="portfolio" type="url" placeholder="https://vimeo.com/you" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="social">Social Media</Label>
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>

View file

@ -0,0 +1,547 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import {
SKATE_PHOTOGRAPHER_KIND,
SKATE_SPOT_KIND,
PHOTO_SERVICE_LABELS,
PHOTO_SERVICE_DESCRIPTIONS,
PHOTO_CAMERA_LABELS,
PHOTO_LENS_LABELS,
PHOTO_LIGHTING_LABELS,
PHOTO_SUPPORT_LABELS,
PHOTO_STYLE_LABELS,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Aperture } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
const CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS);
const LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS);
const LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS);
const SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS);
const STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS);
// Small reusable multi-select chip grid
function ChipGrid({
entries,
selected,
onToggle,
}: {
entries: [string, string][];
selected: string[];
onToggle: (key: string) => void;
}) {
return (
<div className="flex flex-wrap gap-2">
{entries.map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => onToggle(key)}
className={cn(
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
selected.includes(key)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
</button>
))}
</div>
);
}
export default function BecomePhotographerPage() {
useSeoMeta({
title: 'Become a Photographer - SkateSpots',
description: 'Create your skate photography profile — cameras, lenses, lighting and services.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const hasWallet = !!webln || !!activeNWC;
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [selectedCameras, setSelectedCameras] = useState<string[]>([]);
const [selectedLenses, setSelectedLenses] = useState<string[]>([]);
const [selectedLighting, setSelectedLighting] = useState<string[]>([]);
const [selectedSupport, setSelectedSupport] = useState<string[]>([]);
const [selectedStyles, setSelectedStyles] = useState<string[]>([]);
const [gear, setGear] = useState('');
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState('');
const [rate, setRate] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name;
if (user?.pubkey) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return 'Anonymous Photographer';
};
const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
setter((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key]));
};
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(error) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImage(url);
toast({ title: 'Image uploaded' });
}
} catch (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
}
e.target.value = '';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one service you offer.', variant: 'destructive' });
return;
}
if (selectedCameras.length === 0) {
toast({ title: 'Camera required', description: 'Select at least one camera type you shoot on.', variant: 'destructive' });
return;
}
try {
const identifier = 'photography-profile';
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', 'skatephotographer'],
['alt', `Skate photographer: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) {
const geohash = ngeohash.encode(lat, lng, 7);
tags.push(['g', geohash]);
}
}
// All selections become queryable `t` tags
for (const s of [
...selectedServices,
...selectedCameras,
...selectedLenses,
...selectedLighting,
...selectedSupport,
...selectedStyles,
]) {
tags.push(['t', s]);
}
if (gear.trim()) tags.push(['gear', gear.trim()]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (rate.trim()) tags.push(['rate', rate.trim()]);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
await createEvent({
kind: SKATE_PHOTOGRAPHER_KIND,
content: bio.trim(),
tags,
});
toast({ title: 'Photography profile created!', description: 'Your profile has been published.' });
navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Camera className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">You need to log in to create a photography profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = user?.metadata?.nip05;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab
</Link>
<h1 className="text-3xl font-black uppercase text-indigo-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
Become a Photographer
</h1>
<p className="text-gray-400 mt-1">Shoot the sessions — offer your skate photography</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
{/* Wallet Requirement */}
<Card className="mt-4 bg-indigo-950/30 border-2 border-indigo-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-indigo-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-indigo-400 mb-2">⚡ Payment Setup Required</h3>
<p className="text-sm text-gray-300 mb-3">
To get paid for shoots, connect a Lightning wallet. Clients zap you directly for your work.
</p>
{hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
<div className="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
<p className="text-sm font-bold text-green-400">Wallet Connected — Ready to Receive Payments!</p>
</div>
<p className="text-xs text-gray-400">{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}</p>
</div>
) : (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" />
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<WalletModal>
<Button className="gap-2 bg-indigo-600 hover:bg-indigo-700 flex-1">
<Wallet className="h-4 w-4" />
Connect Wallet with NWC
</Button>
</WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
<Button variant="outline" className="gap-2 w-full border-indigo-500/50 text-indigo-400 hover:bg-indigo-500/10">
<ExternalLink className="h-4 w-4" />
Learn How
</Button>
</a>
</div>
<p className="text-xs text-gray-400 italic">
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos).
</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-indigo-400" /> Photography Profile
</CardTitle>
<CardDescription className="text-gray-400">Tell riders about your eye, your kit and your services</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Your Photographer Identity</Label>
<div className="p-4 rounded-lg border-2 border-indigo-500/30 bg-indigo-500/5">
<p className="font-black text-lg">{getDisplayName()}</p>
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your background, style, what you love shooting..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Services */}
<div className="space-y-3">
<Label>Services Offered * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => (
<button
key={service}
type="button"
onClick={() => toggle(setSelectedServices)(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedServices.includes(service)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedServices.includes(service) ? 'text-white/80' : 'text-gray-400')}>
{PHOTO_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
</div>
</div>
{/* Camera */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Camera className="h-4 w-4 text-indigo-400" /> Camera Bodies * (select at least one)</Label>
<ChipGrid entries={CAMERA_ENTRIES} selected={selectedCameras} onToggle={toggle(setSelectedCameras)} />
</div>
{/* Lenses */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Aperture className="h-4 w-4 text-indigo-400" /> Lenses</Label>
<ChipGrid entries={LENS_ENTRIES} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
</div>
{/* Lighting */}
<div className="space-y-3">
<Label className="flex items-center gap-2">💡 Lighting Gear</Label>
<ChipGrid entries={LIGHTING_ENTRIES} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
</div>
{/* Support */}
<div className="space-y-3">
<Label className="flex items-center gap-2">🎒 Support & Rig</Label>
<ChipGrid entries={SUPPORT_ENTRIES} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
</div>
{/* Style */}
<div className="space-y-3">
<Label className="flex items-center gap-2">🎨 Shooting Style</Label>
<ChipGrid entries={STYLE_ENTRIES} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
</div>
{/* Freeform gear list */}
<div className="space-y-2">
<Label htmlFor="gear">What's in the Bag (optional)</Label>
<Textarea
id="gear"
placeholder="e.g. Canon R6 II, 8-15mm fisheye, 70-200mm f/2.8, 2× Godox AD200, remote triggers, Manfrotto tripod..."
value={gear}
onChange={(e) => setGear(e.target.value)}
rows={3}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">List exact bodies, lenses and lights — riders love knowing the setup.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="experience">Years Experience</Label>
<Input id="experience" type="number" placeholder="5" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="rate">Rate</Label>
<Input id="rate" placeholder="20000 sats/shoot" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">City / Area</Label>
<Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
{/* Contact */}
<div className="space-y-3">
<Label className="text-base font-bold">Contact & Portfolio</Label>
<p className="text-sm text-gray-400">How riders can see your work and reach you (all optional, but recommended)</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="portfolio">Portfolio / Instagram</Label>
<Input id="portfolio" type="url" placeholder="https://instagram.com/you" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input id="website" type="url" placeholder="https://mywork.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="shooter@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="social">Other Social</Label>
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
<p className="text-sm text-gray-300">💬 Riders can message you directly through SkateSpots' encrypted messaging</p>
</div>
</div>
{/* Spot Picker */}
<SpotPicker
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={5}
label="Spots Where You Shoot"
description="Select spots where you offer photography"
/>
<div className="space-y-2">
<Label>Coordinates (optional)</Label>
<div className="grid grid-cols-2 gap-4">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
</div>
<div className="space-y-2">
<Label>Profile Photo</Label>
{image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" />
</button>
</div>
) : (
<label className="w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
</label>
)}
</div>
{/* Platform fee gate — only when a rate is set */}
{rate.trim() && !feePaid && !isPlatformOwner && (
<PlatformFeePayment
type="labProfile"
onSuccess={() => { /* hook auto-updates */ }}
context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
/>
)}
<Button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 uppercase font-black"
disabled={isPublishing || isUploading || !hasWallet || (!!rate.trim() && !feePaid && !isPlatformOwner)}
>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : !hasWallet ? (
<><Wallet className="h-4 w-4 mr-2" />CONNECT WALLET TO CONTINUE</>
) : rate.trim() && !feePaid && !isPlatformOwner ? (
<><Zap className="h-4 w-4 mr-2" />PAY LISTING FEE ABOVE TO CONTINUE</>
) : (
<><Camera className="h-4 w-4 mr-2" />CREATE PHOTOGRAPHY PROFILE</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}

View file

@ -11,7 +11,7 @@ import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS } from '@/lib/types';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import NotFound from './NotFound';
@ -26,6 +26,9 @@ import {
Instagram,
Calendar,
Video,
Camera,
Scissors,
Aperture,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -66,6 +69,8 @@ export default function CoachDetailPage() {
const isCoach = profile?.profileType === 'coach';
const isFilmer = profile?.profileType === 'filmer';
const isEditor = profile?.profileType === 'editor';
const isPhotographer = profile?.profileType === 'photographer';
useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
@ -85,16 +90,37 @@ export default function CoachDetailPage() {
const npub = nip19.npubEncode(profile.pubkey);
// Per-type accent (text, border, bg, ring) — keeps the detail page on-brand
const accent = isCoach
? { text: 'text-[#ff0040]', bg: 'bg-[#ff0040]', border: 'border-[#ff0040]/30', hover: 'hover:bg-[#ff0040]/10 border-[#ff0040]' }
: isFilmer
? { text: 'text-orange-500', bg: 'bg-orange-500', border: 'border-orange-500/30', hover: 'hover:bg-orange-500/10 border-orange-500' }
: isPhotographer
? { text: 'text-indigo-400', bg: 'bg-indigo-600', border: 'border-indigo-500/30', hover: 'hover:bg-indigo-500/10 border-indigo-500' }
: { text: 'text-purple-400', bg: 'bg-purple-600', border: 'border-purple-500/30', hover: 'hover:bg-purple-500/10 border-purple-500' };
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : Scissors;
// All photographer label maps merged for chip resolution
const photoLabels: Record<string, string> = {
...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS,
...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS,
};
// Get skill labels and descriptions based on profile type
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return photoLabels[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
};
const getSkillDescription = (skill: string) => {
if (isCoach) return COACH_SKILL_DESCRIPTIONS[skill];
if (isFilmer) return FILMING_SERVICE_DESCRIPTIONS[skill] || FILMING_SKILL_DESCRIPTIONS[skill];
if (isPhotographer) return PHOTO_SERVICE_DESCRIPTIONS[skill];
if (isEditor) return EDITOR_SERVICE_DESCRIPTIONS[skill] || EDITOR_SKILL_DESCRIPTIONS[skill];
return undefined;
};
@ -113,24 +139,23 @@ export default function CoachDetailPage() {
{/* Main Content */}
<div className="md:col-span-2 space-y-6">
{/* Profile Card */}
<Card className={`bg-black/90 border-2 ${isCoach ? 'border-[#ff0040]/30' : 'border-orange-500/30'}`}>
<Card className={`bg-black/90 border-2 ${accent.border}`}>
<CardContent className="pt-6">
<div className="flex items-start gap-4 mb-6">
<Avatar className="h-20 w-20">
<AvatarImage src={profile.image || metadata?.picture} alt={profile.title} />
<AvatarFallback className={`${isCoach ? 'bg-[#ff0040]' : 'bg-orange-500'} text-white text-2xl`}>
<AvatarFallback className={`${accent.bg} text-white text-2xl`}>
{profile.title.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className={`text-3xl font-black uppercase ${isCoach ? 'text-[#ff0040]' : 'text-orange-500'}`} style={{
<h1 className={`text-3xl font-black uppercase ${accent.text}`} style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>
{profile.title}
</h1>
{isCoach && <GraduationCap className="h-7 w-7 text-[#ff0040]" />}
{isFilmer && <Video className="h-7 w-7 text-orange-500" />}
<TypeIcon className={`h-7 w-7 ${accent.text}`} />
<TrustBadge pubkey={profile.pubkey} size="md" />
<OfficialBadge pubkey={profile.pubkey} size="md" />
</div>
@ -153,13 +178,13 @@ export default function CoachDetailPage() {
{profile.skills.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3">
{isCoach ? 'Skills' : 'Services & Skills'}
{isCoach ? 'Skills' : isPhotographer ? 'Services & Gear' : 'Services & Skills'}
</h3>
<div className="space-y-3">
{profile.skills.map((skill) => (
<div key={skill} className={`bg-black/50 border rounded-lg p-3 ${isCoach ? 'border-[#ff0040]/30' : 'border-orange-500/30'}`}>
<div key={skill} className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<div className="flex items-start gap-3">
<Badge className={`${isCoach ? 'bg-[#ff0040]' : 'bg-orange-500'} text-white font-bold`}>
<Badge className={`${accent.bg} text-white font-bold`}>
{getSkillLabel(skill)}
</Badge>
{getSkillDescription(skill) && (
@ -174,6 +199,18 @@ export default function CoachDetailPage() {
</div>
)}
{/* In the Bag — photographer / filmer gear (freeform) */}
{(isPhotographer || isFilmer) && 'gear' in profile && profile.gear && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<Aperture className={`h-4 w-4 ${accent.text}`} /> In the Bag
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{profile.gear}
</p>
</div>
)}
{/* Experience & Rate */}
<div className="grid grid-cols-2 gap-4">
{profile.experience && (
@ -243,13 +280,21 @@ export default function CoachDetailPage() {
</a>
)}
{isFilmer && sanitizeUrl(profile.social) && (
{(isFilmer || isPhotographer || isEditor) && 'social' in profile && sanitizeUrl(profile.social) && (
<a href={sanitizeUrl(profile.social)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.social!)}`}>
<Globe className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{urlDomain(profile.social!)}</span>
</a>
)}
{/* Portfolio / reel (filmers, photographers & editors) */}
{(isPhotographer || isEditor || isFilmer) && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
<a href={sanitizeUrl(profile.portfolio)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.portfolio!)}`}>
<Aperture className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">Portfolio · {urlDomain(profile.portfolio!)}</span>
</a>
)}
{/* Lightning Address from Nostr Profile */}
{metadata?.lud16 && (
<div className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded">
@ -273,7 +318,7 @@ export default function CoachDetailPage() {
<Card className="bg-black/90 border-2 border-gray-700">
<CardHeader>
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
<MapPin className={`h-5 w-5 ${isCoach ? 'text-[#ff0040]' : 'text-orange-500'}`} />
<MapPin className={`h-5 w-5 ${accent.text}`} />
Location
</CardTitle>
</CardHeader>

View file

@ -18,6 +18,12 @@ import {
FILMING_SKILL_LABELS,
EDITOR_SERVICE_LABELS,
EDITOR_SKILL_LABELS,
PHOTO_SERVICE_LABELS,
PHOTO_CAMERA_LABELS,
PHOTO_LENS_LABELS,
PHOTO_LIGHTING_LABELS,
PHOTO_SUPPORT_LABELS,
PHOTO_STYLE_LABELS,
} from '@/lib/types';
import {
GraduationCap,
@ -27,6 +33,7 @@ import {
SlidersHorizontal,
Video,
Scissors,
Camera,
CreditCard,
X,
ChevronRight,
@ -45,8 +52,24 @@ const COACH_SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
const FILMING_SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
const EDITOR_SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const EDITOR_SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
const PHOTO_SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
const PHOTO_CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS);
const PHOTO_LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS);
const PHOTO_LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS);
const PHOTO_SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS);
const PHOTO_STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS);
type ServiceType = 'coaching' | 'filming' | 'editing';
// All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_SERVICE_LABELS,
...PHOTO_CAMERA_LABELS,
...PHOTO_LENS_LABELS,
...PHOTO_LIGHTING_LABELS,
...PHOTO_SUPPORT_LABELS,
...PHOTO_STYLE_LABELS,
};
type ServiceType = 'coaching' | 'filming' | 'editing' | 'photography';
// ─── Per-category accent colours ─────────────────────────────────────────────
@ -84,6 +107,17 @@ const ACCENT = {
stripe: 'bg-purple-600',
badgeSkill: 'bg-purple-900/40 border-purple-700/50 text-purple-300',
},
photography: {
active: 'bg-indigo-600 hover:bg-indigo-700 text-white border-indigo-600',
chip: 'bg-indigo-600 text-white border-indigo-600',
chipHover: 'hover:border-indigo-500/60 hover:text-indigo-400',
border: 'border-indigo-500/40',
glow: 'shadow-indigo-500/20',
icon: 'text-indigo-400',
badge: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
stripe: 'bg-indigo-600',
badgeSkill: 'bg-indigo-900/40 border-indigo-700/50 text-indigo-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
@ -105,6 +139,7 @@ function OfferFAB() {
const items = [
{ to: '/become-coach', label: 'Offer Coaching', color: 'bg-[#ff0040] hover:bg-[#ff0040]/90', icon: GraduationCap },
{ to: '/become-filmer', label: 'Offer Filming', color: 'bg-orange-500 hover:bg-orange-600', icon: Video },
{ to: '/become-photographer', label: 'Offer Photography', color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera },
{ to: '/become-editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
];
@ -176,6 +211,7 @@ interface ToolbarProps {
function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'coaching') return COACH_SKILL_LABELS[skill] || skill;
if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
@ -200,6 +236,7 @@ function LabToolbar({
const tabs: { key: ServiceType; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ key: 'coaching', label: 'Coaches', icon: GraduationCap },
{ key: 'filming', label: 'Filmers', icon: Video },
{ key: 'photography', label: 'Photo', icon: Camera },
{ key: 'editing', label: 'Editors', icon: Scissors },
];
@ -216,7 +253,7 @@ function LabToolbar({
return (
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
{/* Tab row */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 sm:gap-2">
{tabs.map(({ key, label, icon: Icon }) => {
const isActive = serviceType === key;
const a = ACCENT[key];
@ -225,7 +262,7 @@ function LabToolbar({
key={key}
onClick={() => onServiceChange(key)}
className={cn(
'relative flex items-center gap-1.5 px-3 py-2 rounded-xl font-black text-xs uppercase tracking-wide transition-all duration-150 active:scale-95 flex-1 justify-center',
'relative flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-2 rounded-xl font-black text-[11px] sm:text-xs uppercase tracking-wide transition-all duration-150 active:scale-95 flex-1 justify-center',
isActive
? a.active
: 'text-gray-500 bg-white/5 hover:bg-white/10 hover:text-white border border-transparent'
@ -408,6 +445,15 @@ function FilterDrawer({
{ title: 'Content Type', entries: FILMING_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Equipment & Style', entries: Object.entries(FILMING_SKILL_LABELS), activeColor: 'bg-orange-400 text-white border-orange-400' },
]
: serviceType === 'photography'
? [
{ title: 'Services', entries: PHOTO_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Camera', entries: PHOTO_CAMERA_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Lenses', entries: PHOTO_LENS_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Lighting', entries: PHOTO_LIGHTING_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Support & Rig', entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Style', entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
]
: [
{ title: 'Edit Type', entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Software & Tools', entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
@ -583,15 +629,17 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
const isCoach = profile.profileType === 'coach';
const isFilmer = profile.profileType === 'filmer';
const isEditor = profile.profileType === 'editor';
const isPhotographer = profile.profileType === 'photographer';
const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : 'editing';
const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : isPhotographer ? 'photography' : 'editing';
const accent = ACCENT[serviceType];
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : Scissors;
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : Scissors;
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return PHOTO_ALL_LABELS[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
};
@ -720,11 +768,19 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
filming: {
icon: Video,
title: 'No filmers yet',
body: 'Be the first to offer skate filming and photography in your area.',
body: 'Be the first to offer skate filming — clips, full parts, contest coverage and more.',
cta: 'Offer Filming',
to: '/become-filmer',
color: 'bg-orange-500 hover:bg-orange-600',
},
photography: {
icon: Camera,
title: 'No photographers yet',
body: 'Be the first to offer skate photography in your area — action shots, sequences, editorial and more.',
cta: 'Offer Photography',
to: '/become-photographer',
color: 'bg-indigo-600 hover:bg-indigo-700',
},
editing: {
icon: Scissors,
title: 'No video editors yet',
@ -771,7 +827,7 @@ export default function LabPage() {
useSeoMeta({
title: 'The Lab - SkateSpots',
description: 'Find skateboarding coaches, filmers, and video editors.',
description: 'Find skateboarding coaches, filmers, photographers, and video editors.',
});
const { user } = useCurrentUser();
@ -820,10 +876,11 @@ export default function LabPage() {
// Counts per tab (unfiltered by skills/search/location, just by type)
const counts = useMemo<Record<ServiceType, number>>(() => {
if (!profiles) return { coaching: 0, filming: 0, editing: 0 };
if (!profiles) return { coaching: 0, filming: 0, editing: 0, photography: 0 };
return {
coaching: profiles.filter((p) => p.profileType === 'coach').length,
filming: profiles.filter((p) => p.profileType === 'filmer').length,
photography: profiles.filter((p) => p.profileType === 'photographer').length,
editing: profiles.filter((p) => p.profileType === 'editor').length,
};
}, [profiles]);
@ -834,6 +891,7 @@ export default function LabPage() {
let filtered = profiles.filter((profile) => {
if (serviceType === 'coaching' && profile.profileType !== 'coach') return false;
if (serviceType === 'filming' && profile.profileType !== 'filmer') return false;
if (serviceType === 'photography' && profile.profileType !== 'photographer') return false;
if (serviceType === 'editing' && profile.profileType !== 'editor') return false;
// Text search: title, bio, and genUserName fallback