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:
parent
88a36e52ae
commit
adeab62aa4
8 changed files with 999 additions and 36 deletions
108
NIP.md
108
NIP.md
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
185
src/lib/types.ts
185
src/lib/types.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
547
src/pages/BecomePhotographerPage.tsx
Normal file
547
src/pages/BecomePhotographerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue