Add coach portfolio fields and gallery to Lab coach profile
Enrich the coach (kind 35001) beyond the shared base schema with the experience/portfolio fields that actually win a booking: - Session formats, levels, age groups (repeated tags, client-filtered) - Self-attested credentials/safety (first-aid, safeguarding, …) with an honest 'not verified' label - Languages spoken (free-form) and availability - Optional booking link - Portfolio photo gallery (up to 12) reusing PhotoDropzone + ImageLightbox - Highlight-reel video link (YouTube/Instagram) Option sets live in src/lib/coachOptions.ts with EN/ES keys. parseSkateCoach exposes the new fields; NIP.md documents the tags. Also fix the pre-existing ErrorBoundary test (ErrorFallback uses useTranslation but the test rendered no LanguageProvider).
This commit is contained in:
parent
ef5a30eb5f
commit
3e534c5ffc
10 changed files with 629 additions and 11 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -58,6 +58,21 @@ session with **no other memory of this project**. So:
|
||||||
`isValidEmail()`, and website/portfolio must resolve through
|
`isValidEmail()`, and website/portfolio must resolve through
|
||||||
`sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and
|
`sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and
|
||||||
blocks publish (`social` is exempt — it may be a bare `@handle`).
|
blocks publish (`social` is exempt — it may be a bare `@handle`).
|
||||||
|
- **Coach profile enrichment (2026-10-04):** the coach (kind `35001`) gains
|
||||||
|
portfolio/experience fields beyond the shared base — repeated named tags
|
||||||
|
(multi-letter, client-filtered like `experience`/`rate`): `format`
|
||||||
|
(one-on-one/group/camp/video-analysis/contest-prep/park-lesson/adaptive/
|
||||||
|
girls-session/after-school), `level`, `age`, `cert` (self-attested
|
||||||
|
first-aid/cpr/safeguarding/background-check/insured/federation — **never shown
|
||||||
|
as verified**), `language` (free-form), `availability`, `booking` (URL),
|
||||||
|
`photo` (repeatable gallery, ≤12) and `video` (YouTube/Instagram reel).
|
||||||
|
Option sets + i18n keys live in **`src/lib/coachOptions.ts`**; the form renders
|
||||||
|
a reusable `OptionChips` row; the gallery reuses **`PhotoDropzone`** +
|
||||||
|
**`ImageLightbox`**; the reel reuses **`parseCrewVideo`**
|
||||||
|
(`src/lib/videoEmbed.ts`). `parseSkateCoach` exposes `formats`/`levels`/
|
||||||
|
`ageGroups`/`credentials`/`languages`/`availability`/`booking`/`photos`/`video`
|
||||||
|
on `SkateCoach`. Documented in NIP.md → "Coach (kind 35001)". Not yet done:
|
||||||
|
coach testimonials (31873-style) and `/lab` discovery filters.
|
||||||
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
|
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
|
||||||
image input — **drag & drop + multi-select**, emits `File[]` to its caller
|
image input — **drag & drop + multi-select**, emits `File[]` to its caller
|
||||||
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
|
||||||
|
|
|
||||||
28
NIP.md
28
NIP.md
|
|
@ -2046,6 +2046,34 @@ voucher's web-of-trust score, so a handful of vouches from trusted skaters
|
||||||
outweighs many from throwaway keys. A provider cannot vouch for themselves
|
outweighs many from throwaway keys. A provider cannot vouch for themselves
|
||||||
(self-vouches are ignored). Vouches are revocable via NIP-09 deletion.
|
(self-vouches are ignored). Vouches are revocable via NIP-09 deletion.
|
||||||
|
|
||||||
|
### Coach (kind 35001)
|
||||||
|
|
||||||
|
Skate coaches teaching tricks, terrain and fundamentals. Uses the common base
|
||||||
|
schema; skills are encoded as `t` tags (e.g. `basics`, `ollies`, `kickflips`,
|
||||||
|
`grinds`, `manuals`, `inverts`, `vert`, `bowl`, `street`, `freestyle`, `safety`,
|
||||||
|
`diy`).
|
||||||
|
|
||||||
|
Coach-specific optional tags record how (and to whom) they coach, plus
|
||||||
|
self-attested safety credentials. All are repeatable and lowercased; like
|
||||||
|
`experience`/`rate` they are **not relay-indexed**, so clients filter them
|
||||||
|
client-side.
|
||||||
|
|
||||||
|
| Tag | Values | Description |
|
||||||
|
|-----|--------|-------------|
|
||||||
|
| `format` | `one-on-one`, `group`, `camp`, `video-analysis`, `contest-prep`, `park-lesson`, `adaptive`, `girls-session`, `after-school` | How sessions are delivered |
|
||||||
|
| `level` | `beginner`, `intermediate`, `advanced`, `competition` | Levels taught |
|
||||||
|
| `age` | `kids`, `teens`, `adults`, `seniors` | Age groups coached |
|
||||||
|
| `cert` | `first-aid`, `cpr`, `safeguarding`, `background-check`, `insured`, `federation` | Self-attested credentials / safety |
|
||||||
|
| `language` | free-form (e.g. `English`, `Español`) | Languages spoken |
|
||||||
|
| `availability` | `weekdays`, `weekends`, `evenings`, `after-school`, `school-holidays`, `seasonal` | When available |
|
||||||
|
| `booking` | URL | Optional booking / scheduling link |
|
||||||
|
| `photo` | URL | Repeatable — portfolio gallery image (max 12) |
|
||||||
|
| `video` | URL | Optional highlight-reel link (YouTube / Instagram) |
|
||||||
|
|
||||||
|
> **Trust note:** `cert` tags are **self-attested**. SkateSpots does not verify
|
||||||
|
> them — only a verified NIP-05 domain (or a NIP-32 moderation verdict) is a real
|
||||||
|
> signal. Clients must label credentials as self-attested, never as verified.
|
||||||
|
|
||||||
### Photographer (kind 35004)
|
### Photographer (kind 35004)
|
||||||
|
|
||||||
Skate photography is a distinct craft from filming — it is shot-, light-, and
|
Skate photography is a distinct craft from filming — it is shot-, light-, and
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.5.11",
|
"version": "1.5.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"version": "1.5.11",
|
"version": "1.5.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ const en = {
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
|
add: 'Add',
|
||||||
|
remove: 'Remove',
|
||||||
create: 'Create',
|
create: 'Create',
|
||||||
submit: 'Submit',
|
submit: 'Submit',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
|
|
@ -2167,6 +2169,24 @@ labUi: {
|
||||||
selectSkill: 'Select at least one skill.',
|
selectSkill: 'Select at least one skill.',
|
||||||
skillsLabel: 'Skills I Teach * (select at least one)',
|
skillsLabel: 'Skills I Teach * (select at least one)',
|
||||||
skillsRequired: 'Skills required',
|
skillsRequired: 'Skills required',
|
||||||
|
formatsLabel: 'Session formats',
|
||||||
|
formatsHint: 'How you deliver coaching.',
|
||||||
|
levelsLabel: 'Levels taught',
|
||||||
|
agesLabel: 'Age groups',
|
||||||
|
credentialsLabel: 'Credentials & safety',
|
||||||
|
credentialsHint: 'Self-attested — SkateSpots does not verify these.',
|
||||||
|
languagesLabel: 'Languages spoken',
|
||||||
|
languagesHint: 'Add the languages you can coach in.',
|
||||||
|
languagePlaceholder: 'e.g. English, Español',
|
||||||
|
availabilityLabel: 'Availability',
|
||||||
|
bookingLabel: 'Booking link',
|
||||||
|
bookingHint: 'Optional — a page where students can book and pay.',
|
||||||
|
galleryLabel: 'Portfolio photos',
|
||||||
|
galleryHint: 'Show real sessions, students and spots (up to 12).',
|
||||||
|
galleryAdd: 'Add portfolio photos',
|
||||||
|
galleryUploaded: '{{count}} photo(s) uploaded',
|
||||||
|
videoLabel: 'Highlight reel',
|
||||||
|
videoHint: 'Optional — a YouTube or Instagram video of your coaching.',
|
||||||
spotsDesc: 'Select spots where you offer coaching sessions',
|
spotsDesc: 'Select spots where you offer coaching sessions',
|
||||||
spotsLabel: 'Spots Where You Coach',
|
spotsLabel: 'Spots Where You Coach',
|
||||||
tagline: 'Share your skateboarding knowledge',
|
tagline: 'Share your skateboarding knowledge',
|
||||||
|
|
@ -2470,6 +2490,16 @@ labUi: {
|
||||||
eventsOrganized: 'Events Organized',
|
eventsOrganized: 'Events Organized',
|
||||||
spots: 'Spots',
|
spots: 'Spots',
|
||||||
viewSpot: 'View spot',
|
viewSpot: 'View spot',
|
||||||
|
credentialsTitle: 'Credentials & safety',
|
||||||
|
selfAttested: 'Self-attested by the coach — not verified by SkateSpots.',
|
||||||
|
formats: 'Formats',
|
||||||
|
levels: 'Levels',
|
||||||
|
ages: 'Ages',
|
||||||
|
languages: 'Languages',
|
||||||
|
availability: 'Availability',
|
||||||
|
bookSession: 'Book a session',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
watchReel: 'Watch on {{platform}}',
|
||||||
serviceArea: 'Service Area',
|
serviceArea: 'Service Area',
|
||||||
licensedInsured: 'Licensed / Insured',
|
licensedInsured: 'Licensed / Insured',
|
||||||
studio: 'Studio',
|
studio: 'Studio',
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ const es = {
|
||||||
cancel: 'Cancelar',
|
cancel: 'Cancelar',
|
||||||
delete: 'Eliminar',
|
delete: 'Eliminar',
|
||||||
edit: 'Editar',
|
edit: 'Editar',
|
||||||
|
add: 'Añadir',
|
||||||
|
remove: 'Quitar',
|
||||||
create: 'Crear',
|
create: 'Crear',
|
||||||
submit: 'Enviar',
|
submit: 'Enviar',
|
||||||
close: 'Cerrar',
|
close: 'Cerrar',
|
||||||
|
|
@ -2167,6 +2169,24 @@ labUi: {
|
||||||
selectSkill: 'Selecciona al menos un truco.',
|
selectSkill: 'Selecciona al menos un truco.',
|
||||||
skillsLabel: 'Trucos que enseño * (selecciona al menos uno)',
|
skillsLabel: 'Trucos que enseño * (selecciona al menos uno)',
|
||||||
skillsRequired: 'Trucos obligatorios',
|
skillsRequired: 'Trucos obligatorios',
|
||||||
|
formatsLabel: 'Formatos de sesión',
|
||||||
|
formatsHint: 'Cómo impartes las clases.',
|
||||||
|
levelsLabel: 'Niveles que enseñas',
|
||||||
|
agesLabel: 'Grupos de edad',
|
||||||
|
credentialsLabel: 'Credenciales y seguridad',
|
||||||
|
credentialsHint: 'Declaradas por el entrenador — SkateSpots no las verifica.',
|
||||||
|
languagesLabel: 'Idiomas',
|
||||||
|
languagesHint: 'Añade los idiomas en los que puedes dar clase.',
|
||||||
|
languagePlaceholder: 'p. ej. Español, English',
|
||||||
|
availabilityLabel: 'Disponibilidad',
|
||||||
|
bookingLabel: 'Enlace de reserva',
|
||||||
|
bookingHint: 'Opcional — una página donde los alumnos pueden reservar y pagar.',
|
||||||
|
galleryLabel: 'Fotos de tu portfolio',
|
||||||
|
galleryHint: 'Muestra sesiones reales, alumnos y spots (hasta 12).',
|
||||||
|
galleryAdd: 'Añadir fotos del portfolio',
|
||||||
|
galleryUploaded: '{{count}} foto(s) subida(s)',
|
||||||
|
videoLabel: 'Vídeo destacado',
|
||||||
|
videoHint: 'Opcional — un vídeo de YouTube o Instagram de tus clases.',
|
||||||
spotsDesc: 'Selecciona los spots donde ofreces clases',
|
spotsDesc: 'Selecciona los spots donde ofreces clases',
|
||||||
spotsLabel: 'Spots donde entrenas',
|
spotsLabel: 'Spots donde entrenas',
|
||||||
tagline: 'Comparte tus conocimientos de skate',
|
tagline: 'Comparte tus conocimientos de skate',
|
||||||
|
|
@ -2470,6 +2490,16 @@ labUi: {
|
||||||
eventsOrganized: 'Eventos organizados',
|
eventsOrganized: 'Eventos organizados',
|
||||||
spots: 'Spots',
|
spots: 'Spots',
|
||||||
viewSpot: 'Ver spot',
|
viewSpot: 'Ver spot',
|
||||||
|
credentialsTitle: 'Credenciales y seguridad',
|
||||||
|
selfAttested: 'Declaradas por el entrenador — no verificadas por SkateSpots.',
|
||||||
|
formats: 'Formatos',
|
||||||
|
levels: 'Niveles',
|
||||||
|
ages: 'Edades',
|
||||||
|
languages: 'Idiomas',
|
||||||
|
availability: 'Disponibilidad',
|
||||||
|
bookSession: 'Reservar una sesión',
|
||||||
|
portfolio: 'Portfolio',
|
||||||
|
watchReel: 'Ver en {{platform}}',
|
||||||
serviceArea: 'Zona de servicio',
|
serviceArea: 'Zona de servicio',
|
||||||
licensedInsured: 'Con licencia / asegurado',
|
licensedInsured: 'Con licencia / asegurado',
|
||||||
studio: 'Estudio',
|
studio: 'Estudio',
|
||||||
|
|
|
||||||
57
src/lib/coachOptions.ts
Normal file
57
src/lib/coachOptions.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Curated, translatable option sets for the coach profile. `value` is what gets
|
||||||
|
* stored in the event tag (lowercase slug); `key` is the i18n key to display.
|
||||||
|
*/
|
||||||
|
export interface CoachOption {
|
||||||
|
value: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COACH_FORMATS: CoachOption[] = [
|
||||||
|
{ value: 'one-on-one', key: 'labUi.coachForm.formatOneOnOne' },
|
||||||
|
{ value: 'group', key: 'labUi.coachForm.formatGroup' },
|
||||||
|
{ value: 'camp', key: 'labUi.coachForm.formatCamp' },
|
||||||
|
{ value: 'video-analysis', key: 'labUi.coachForm.formatVideo' },
|
||||||
|
{ value: 'contest-prep', key: 'labUi.coachForm.formatContestPrep' },
|
||||||
|
{ value: 'park-lesson', key: 'labUi.coachForm.formatParkLesson' },
|
||||||
|
{ value: 'adaptive', key: 'labUi.coachForm.formatAdaptive' },
|
||||||
|
{ value: 'girls-session', key: 'labUi.coachForm.formatGirls' },
|
||||||
|
{ value: 'after-school', key: 'labUi.coachForm.formatAfterSchool' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COACH_LEVELS: CoachOption[] = [
|
||||||
|
{ value: 'beginner', key: 'labUi.coachForm.levelBeginner' },
|
||||||
|
{ value: 'intermediate', key: 'labUi.coachForm.levelIntermediate' },
|
||||||
|
{ value: 'advanced', key: 'labUi.coachForm.levelAdvanced' },
|
||||||
|
{ value: 'competition', key: 'labUi.coachForm.levelCompetition' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COACH_AGE_GROUPS: CoachOption[] = [
|
||||||
|
{ value: 'kids', key: 'labUi.coachForm.ageKids' },
|
||||||
|
{ value: 'teens', key: 'labUi.coachForm.ageTeens' },
|
||||||
|
{ value: 'adults', key: 'labUi.coachForm.ageAdults' },
|
||||||
|
{ value: 'seniors', key: 'labUi.coachForm.ageSeniors' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COACH_CREDENTIALS: CoachOption[] = [
|
||||||
|
{ value: 'first-aid', key: 'labUi.coachForm.certFirstAid' },
|
||||||
|
{ value: 'cpr', key: 'labUi.coachForm.certCpr' },
|
||||||
|
{ value: 'safeguarding', key: 'labUi.coachForm.certSafeguarding' },
|
||||||
|
{ value: 'background-check', key: 'labUi.coachForm.certBackgroundCheck' },
|
||||||
|
{ value: 'insured', key: 'labUi.coachForm.certInsured' },
|
||||||
|
{ value: 'federation', key: 'labUi.coachForm.certFederation' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COACH_AVAILABILITY: CoachOption[] = [
|
||||||
|
{ value: 'weekdays', key: 'labUi.coachForm.availWeekdays' },
|
||||||
|
{ value: 'weekends', key: 'labUi.coachForm.availWeekends' },
|
||||||
|
{ value: 'evenings', key: 'labUi.coachForm.availEvenings' },
|
||||||
|
{ value: 'after-school', key: 'labUi.coachForm.availAfterSchool' },
|
||||||
|
{ value: 'school-holidays', key: 'labUi.coachForm.availHolidays' },
|
||||||
|
{ value: 'seasonal', key: 'labUi.coachForm.availSeasonal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Display label for a stored option value (falls back to the raw value). */
|
||||||
|
export function coachOptionKey(options: CoachOption[], value: string): string | undefined {
|
||||||
|
return options.find((o) => o.value === value)?.key;
|
||||||
|
}
|
||||||
|
|
@ -2335,6 +2335,24 @@ export interface SkateCoach {
|
||||||
skills: string[];
|
skills: string[];
|
||||||
experience?: string;
|
experience?: string;
|
||||||
rate?: string;
|
rate?: string;
|
||||||
|
/** Session formats taught (1-on-1, group, camp, …). */
|
||||||
|
formats: string[];
|
||||||
|
/** Levels taught (beginner → competition). */
|
||||||
|
levels: string[];
|
||||||
|
/** Age groups (kids / teens / adults / seniors). */
|
||||||
|
ageGroups: string[];
|
||||||
|
/** Self-attested credentials (first-aid, safeguarding, …). */
|
||||||
|
credentials: string[];
|
||||||
|
/** Languages spoken. */
|
||||||
|
languages: string[];
|
||||||
|
/** Availability windows (weekends, evenings, …). */
|
||||||
|
availability: string[];
|
||||||
|
/** Optional booking link. */
|
||||||
|
booking?: string;
|
||||||
|
/** Coaching portfolio gallery image URLs (repeatable `photo` tags). */
|
||||||
|
photos: string[];
|
||||||
|
/** Optional highlight-reel video URL (YouTube / Instagram). */
|
||||||
|
video?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
|
|
@ -2648,6 +2666,26 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||||
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecoach').map(([, value]) => value);
|
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecoach').map(([, value]) => value);
|
||||||
|
// Repeatable, deduped string tags (formats/levels/ages/credentials/languages/availability).
|
||||||
|
const values = (name: string): string[] => [
|
||||||
|
...new Set(
|
||||||
|
event.tags.filter(([n, v]) => n === name && !!v?.trim()).map(([, v]) => v.trim().toLowerCase()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const formats = values('format');
|
||||||
|
const levels = values('level');
|
||||||
|
const ageGroups = values('age');
|
||||||
|
const credentials = values('cert');
|
||||||
|
const languages = values('language');
|
||||||
|
const availability = values('availability');
|
||||||
|
const booking = event.tags.find(([name]) => name === 'booking')?.[1];
|
||||||
|
// Portfolio gallery (`photo` tags) — URLs are case-sensitive, so no lowercasing.
|
||||||
|
const photos = event.tags
|
||||||
|
.filter(([n, v]) => n === 'photo' && !!v?.trim())
|
||||||
|
.map(([, v]) => v.trim())
|
||||||
|
.filter((v, i, arr) => arr.indexOf(v) === i)
|
||||||
|
.slice(0, 12);
|
||||||
|
const video = event.tags.find(([name]) => name === 'video')?.[1];
|
||||||
const experience = event.tags.find(([name]) => name === 'experience')?.[1];
|
const experience = event.tags.find(([name]) => name === 'experience')?.[1];
|
||||||
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
|
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
|
||||||
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
|
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
|
||||||
|
|
@ -2683,6 +2721,15 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
entityType,
|
entityType,
|
||||||
members,
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
|
formats,
|
||||||
|
levels,
|
||||||
|
ageGroups,
|
||||||
|
credentials,
|
||||||
|
languages,
|
||||||
|
availability,
|
||||||
|
booking,
|
||||||
|
photos,
|
||||||
|
video,
|
||||||
crewRef: parseCrewRef(event),
|
crewRef: parseCrewRef(event),
|
||||||
featuredUntil: parseFeaturedUntil(event),
|
featuredUntil: parseFeaturedUntil(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,15 @@ import { WalletModal } from '@/components/WalletModal';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { Nip05Required } from '@/components/Nip05Required';
|
import { Nip05Required } from '@/components/Nip05Required';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
||||||
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
import { CrewLinkField } from '@/components/CrewLinkField';
|
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||||
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
|
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
|
||||||
import { COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS } from '@/lib/labelKeys';
|
import { COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS } from '@/lib/labelKeys';
|
||||||
|
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, type CoachOption } from '@/lib/coachOptions';
|
||||||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -34,6 +37,52 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
||||||
|
|
||||||
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
|
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
|
||||||
|
|
||||||
|
/** Reusable chip toggle row for the curated coach option sets. */
|
||||||
|
function OptionChips({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
idPrefix,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
options: CoachOption[];
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (value: string) => void;
|
||||||
|
idPrefix: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{hint && <p className="text-xs text-gray-500">{hint}</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{options.map((o) => {
|
||||||
|
const active = selected.includes(o.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${idPrefix}-${o.value}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(o.value)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-full border text-sm transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-red-500 text-white border-red-500'
|
||||||
|
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(o.key)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BecomeCoachPage() {
|
export default function BecomeCoachPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
|
|
@ -69,6 +118,16 @@ export default function BecomeCoachPage() {
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
|
const [formats, setFormats] = useState<string[]>([]);
|
||||||
|
const [levels, setLevels] = useState<string[]>([]);
|
||||||
|
const [ageGroups, setAgeGroups] = useState<string[]>([]);
|
||||||
|
const [credentials, setCredentials] = useState<string[]>([]);
|
||||||
|
const [languages, setLanguages] = useState<string[]>([]);
|
||||||
|
const [languageInput, setLanguageInput] = useState('');
|
||||||
|
const [availability, setAvailability] = useState<string[]>([]);
|
||||||
|
const [booking, setBooking] = useState('');
|
||||||
|
const [photos, setPhotos] = useState<string[]>([]);
|
||||||
|
const [video, setVideo] = useState('');
|
||||||
const [entityType, setEntityType] = useState<EntityType>('individual');
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
const [members, setMembers] = useState<string[]>([]);
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -98,6 +157,15 @@ export default function BecomeCoachPage() {
|
||||||
setWebsite(tag('website') ?? '');
|
setWebsite(tag('website') ?? '');
|
||||||
setInstagram(tag('instagram') ?? '');
|
setInstagram(tag('instagram') ?? '');
|
||||||
setNoffer(tag('noffer') ?? '');
|
setNoffer(tag('noffer') ?? '');
|
||||||
|
setFormats(tags('format'));
|
||||||
|
setLevels(tags('level'));
|
||||||
|
setAgeGroups(tags('age'));
|
||||||
|
setCredentials(tags('cert'));
|
||||||
|
setLanguages(tags('language'));
|
||||||
|
setAvailability(tags('availability'));
|
||||||
|
setBooking(tag('booking') ?? '');
|
||||||
|
setPhotos(tags('photo'));
|
||||||
|
setVideo(tag('video') ?? '');
|
||||||
setEntityType((tag('entity') as EntityType) ?? 'individual');
|
setEntityType((tag('entity') as EntityType) ?? 'individual');
|
||||||
setMembers(tags('p'));
|
setMembers(tags('p'));
|
||||||
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]);
|
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]);
|
||||||
|
|
@ -125,6 +193,17 @@ export default function BecomeCoachPage() {
|
||||||
setSelectedSkills((prev) => prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]);
|
setSelectedSkills((prev) => prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleValue = (list: string[], setList: (v: string[]) => void, value: string) => {
|
||||||
|
setList(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLanguage = () => {
|
||||||
|
const v = languageInput.trim();
|
||||||
|
if (!v) return;
|
||||||
|
setLanguages((prev) => (prev.some((l) => l.toLowerCase() === v.toLowerCase()) ? prev : [...prev, v]));
|
||||||
|
setLanguageInput('');
|
||||||
|
};
|
||||||
|
|
||||||
const getCurrentLocation = () => {
|
const getCurrentLocation = () => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||||||
|
|
@ -164,6 +243,25 @@ export default function BecomeCoachPage() {
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGalleryUpload = async (files: File[]) => {
|
||||||
|
const room = 12 - photos.length;
|
||||||
|
if (room <= 0) return;
|
||||||
|
try {
|
||||||
|
const uploaded: string[] = [];
|
||||||
|
for (const file of files.slice(0, room)) {
|
||||||
|
const tags = await uploadFile(file);
|
||||||
|
const url = tags[0]?.[1];
|
||||||
|
if (url) uploaded.push(url);
|
||||||
|
}
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
setPhotos((prev) => [...prev, ...uploaded].slice(0, 12));
|
||||||
|
toast({ title: t('labUi.coachForm.galleryUploaded', { count: uploaded.length }) });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
||||||
// be stored and then silently hidden.
|
// be stored and then silently hidden.
|
||||||
const contactError = email.trim() && !isValidEmail(email)
|
const contactError = email.trim() && !isValidEmail(email)
|
||||||
|
|
@ -220,6 +318,16 @@ export default function BecomeCoachPage() {
|
||||||
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
||||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||||
|
|
||||||
|
for (const v of formats) tags.push(['format', v]);
|
||||||
|
for (const v of levels) tags.push(['level', v]);
|
||||||
|
for (const v of ageGroups) tags.push(['age', v]);
|
||||||
|
for (const v of credentials) tags.push(['cert', v]);
|
||||||
|
for (const v of languages) tags.push(['language', v]);
|
||||||
|
for (const v of availability) tags.push(['availability', v]);
|
||||||
|
if (booking.trim()) tags.push(['booking', normalizeUrlInput(booking)]);
|
||||||
|
for (const p of photos) tags.push(['photo', p]);
|
||||||
|
if (video.trim()) tags.push(['video', video.trim()]);
|
||||||
|
|
||||||
tags.push(['entity', entityType]);
|
tags.push(['entity', entityType]);
|
||||||
if (entityType === 'collective') {
|
if (entityType === 'collective') {
|
||||||
for (const pk of members) tags.push(['p', pk]);
|
for (const pk of members) tags.push(['p', pk]);
|
||||||
|
|
@ -426,6 +534,96 @@ export default function BecomeCoachPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OptionChips
|
||||||
|
label={t('labUi.coachForm.formatsLabel')}
|
||||||
|
hint={t('labUi.coachForm.formatsHint')}
|
||||||
|
options={COACH_FORMATS}
|
||||||
|
selected={formats}
|
||||||
|
onToggle={(v) => toggleValue(formats, setFormats, v)}
|
||||||
|
idPrefix="format"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionChips
|
||||||
|
label={t('labUi.coachForm.levelsLabel')}
|
||||||
|
options={COACH_LEVELS}
|
||||||
|
selected={levels}
|
||||||
|
onToggle={(v) => toggleValue(levels, setLevels, v)}
|
||||||
|
idPrefix="level"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionChips
|
||||||
|
label={t('labUi.coachForm.agesLabel')}
|
||||||
|
options={COACH_AGE_GROUPS}
|
||||||
|
selected={ageGroups}
|
||||||
|
onToggle={(v) => toggleValue(ageGroups, setAgeGroups, v)}
|
||||||
|
idPrefix="age"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionChips
|
||||||
|
label={t('labUi.coachForm.credentialsLabel')}
|
||||||
|
hint={t('labUi.coachForm.credentialsHint')}
|
||||||
|
options={COACH_CREDENTIALS}
|
||||||
|
selected={credentials}
|
||||||
|
onToggle={(v) => toggleValue(credentials, setCredentials, v)}
|
||||||
|
idPrefix="cert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language">{t('labUi.coachForm.languagesLabel')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.languagesHint')}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="language"
|
||||||
|
value={languageInput}
|
||||||
|
onChange={(e) => setLanguageInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addLanguage(); } }}
|
||||||
|
placeholder={t('labUi.coachForm.languagePlaceholder')}
|
||||||
|
className="bg-gray-800 border-gray-700"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={addLanguage} className="border-gray-700 shrink-0">
|
||||||
|
{t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{languages.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<span key={lang} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full border border-gray-700 bg-gray-800 text-sm">
|
||||||
|
{lang}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLanguages((prev) => prev.filter((l) => l !== lang))}
|
||||||
|
aria-label={`${t('common.remove')} ${lang}`}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionChips
|
||||||
|
label={t('labUi.coachForm.availabilityLabel')}
|
||||||
|
options={COACH_AVAILABILITY}
|
||||||
|
selected={availability}
|
||||||
|
onToggle={(v) => toggleValue(availability, setAvailability, v)}
|
||||||
|
idPrefix="availability"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="booking">{t('labUi.coachForm.bookingLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="booking"
|
||||||
|
type="url"
|
||||||
|
value={booking}
|
||||||
|
onChange={(e) => setBooking(e.target.value)}
|
||||||
|
placeholder="https://cal.com/yourname"
|
||||||
|
className="bg-gray-800 border-gray-700"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.bookingHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
|
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
|
||||||
|
|
@ -513,6 +711,50 @@ export default function BecomeCoachPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>{t('labUi.coachForm.galleryLabel')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.galleryHint')}</p>
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||||
|
{photos.map((url) => (
|
||||||
|
<div key={url} className="relative aspect-square rounded-lg overflow-hidden bg-gray-800">
|
||||||
|
<ImageWithFallback src={url} alt="" className="h-full w-full object-cover" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPhotos((prev) => prev.filter((p) => p !== url))}
|
||||||
|
className="absolute top-1 right-1 p-1 rounded-full bg-black/80 hover:bg-red-500"
|
||||||
|
aria-label={t('common.remove')}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{photos.length < 12 && (
|
||||||
|
<PhotoDropzone
|
||||||
|
onFiles={handleGalleryUpload}
|
||||||
|
uploading={isUploading}
|
||||||
|
disabled={isPublishing}
|
||||||
|
className="aspect-video"
|
||||||
|
label={t('labUi.coachForm.galleryAdd')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="video">{t('labUi.coachForm.videoLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="video"
|
||||||
|
type="url"
|
||||||
|
value={video}
|
||||||
|
onChange={(e) => setVideo(e.target.value)}
|
||||||
|
placeholder="https://youtube.com/watch?v=…"
|
||||||
|
className="bg-gray-800 border-gray-700"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.videoHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Optional Pro upgrade — free to list without it */}
|
{/* Optional Pro upgrade — free to list without it */}
|
||||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { PageError } from '@/components/ErrorState';
|
import { PageError } from '@/components/ErrorState';
|
||||||
|
import { ImageLightbox } from '@/components/ImageLightbox';
|
||||||
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
|
import { parseCrewVideo } from '@/lib/videoEmbed';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -37,6 +41,7 @@ import {
|
||||||
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
|
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
|
||||||
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
|
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
|
||||||
} from '@/lib/labelKeys';
|
} from '@/lib/labelKeys';
|
||||||
|
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, coachOptionKey, type CoachOption } from '@/lib/coachOptions';
|
||||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
|
|
@ -66,6 +71,12 @@ import {
|
||||||
Gavel,
|
Gavel,
|
||||||
Tent,
|
Tent,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
ShieldCheck,
|
||||||
|
Languages as LanguagesIcon,
|
||||||
|
CalendarClock,
|
||||||
|
Images,
|
||||||
|
Clapperboard,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
|
@ -105,6 +116,7 @@ export default function CoachDetailPage() {
|
||||||
const author = useAuthor(profile?.pubkey);
|
const author = useAuthor(profile?.pubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// Gigs — only relevant for musicians. The hook is disabled for other types.
|
// Gigs — only relevant for musicians. The hook is disabled for other types.
|
||||||
const { data: gigs = [] } = useGigs(
|
const { data: gigs = [] } = useGigs(
|
||||||
|
|
@ -175,6 +187,18 @@ export default function CoachDetailPage() {
|
||||||
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||||
};
|
};
|
||||||
const pbool = (key: string): boolean => pf[key] === true;
|
const pbool = (key: string): boolean => pf[key] === true;
|
||||||
|
/** Repeatable string-array field on the union profile (empty when absent). */
|
||||||
|
const parr = (key: string): string[] => {
|
||||||
|
const v = pf[key];
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
};
|
||||||
|
/** Translated label for a stored option value, falling back to the raw value. */
|
||||||
|
const optLabel = (options: CoachOption[], value: string): string => {
|
||||||
|
const key = coachOptionKey(options, value);
|
||||||
|
return key ? t(key) : value;
|
||||||
|
};
|
||||||
|
const galleryImages = parr('photos').map((url) => ({ url }));
|
||||||
|
const videoEmbed = parseCrewVideo(pget('video'));
|
||||||
|
|
||||||
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
|
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
|
||||||
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
|
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
|
||||||
|
|
@ -376,6 +400,144 @@ export default function CoachDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Coach — credentials, formats, levels, ages, languages, availability */}
|
||||||
|
{isCoach && (parr('credentials').length > 0 || parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0 || parr('languages').length > 0 || parr('availability').length > 0 || pget('booking')) && (
|
||||||
|
<div className="mb-6 space-y-5">
|
||||||
|
{parr('credentials').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||||
|
<ShieldCheck className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.credentialsTitle')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{parr('credentials').map((c) => (
|
||||||
|
<span key={c} className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
{optLabel(COACH_CREDENTIALS, c)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1.5">{t('labUi.detail.selfAttested')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0) && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{parr('formats').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1.5">{t('labUi.detail.formats')}</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{parr('formats').map((v) => (
|
||||||
|
<span key={v} className={`text-xs px-2.5 py-1 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_FORMATS, v)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parr('levels').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1.5">{t('labUi.detail.levels')}</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{parr('levels').map((v) => (
|
||||||
|
<span key={v} className={`text-xs px-2.5 py-1 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_LEVELS, v)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parr('ageGroups').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1.5">{t('labUi.detail.ages')}</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{parr('ageGroups').map((v) => (
|
||||||
|
<span key={v} className={`text-xs px-2.5 py-1 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_AGE_GROUPS, v)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parr('languages').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||||
|
<LanguagesIcon className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.languages')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{parr('languages').map((l) => (
|
||||||
|
<span key={l} className={`text-xs px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>{l}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(parr('availability').length > 0 || pget('booking')) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||||
|
<CalendarClock className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.availability')}
|
||||||
|
</h3>
|
||||||
|
{parr('availability').length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{parr('availability').map((v) => (
|
||||||
|
<span key={v} className={`text-xs px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_AVAILABILITY, v)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sanitizeUrl(pget('booking')) && (
|
||||||
|
<a
|
||||||
|
href={sanitizeUrl(pget('booking'))!}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`mt-3 inline-flex items-center gap-1.5 text-sm font-bold px-4 py-2 rounded-full ${accent.bg} text-white hover:opacity-90`}
|
||||||
|
>
|
||||||
|
<CalendarClock className="h-4 w-4" />
|
||||||
|
{t('labUi.detail.bookSession')}
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coach — portfolio gallery & highlight reel */}
|
||||||
|
{isCoach && (galleryImages.length > 0 || videoEmbed) && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||||
|
<Images className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.portfolio')}
|
||||||
|
</h3>
|
||||||
|
{galleryImages.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{galleryImages.map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={img.url}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLightboxIndex(i)}
|
||||||
|
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 group"
|
||||||
|
>
|
||||||
|
<ImageWithFallback src={img.url} alt="" className="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{videoEmbed && (
|
||||||
|
<a
|
||||||
|
href={videoEmbed.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-full ${accent.bg} text-white font-bold text-sm hover:opacity-90`}
|
||||||
|
>
|
||||||
|
<Clapperboard className="h-4 w-4" />
|
||||||
|
{t('labUi.detail.watchReel', { platform: videoEmbed.source === 'youtube' ? 'YouTube' : 'Instagram' })}
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<ImageLightbox
|
||||||
|
images={galleryImages}
|
||||||
|
index={lightboxIndex}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* In the Bag — photographer / filmer gear (freeform) */}
|
{/* In the Bag — photographer / filmer gear (freeform) */}
|
||||||
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
|
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
import { LanguageProvider } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,9 +16,11 @@ const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
|
||||||
describe('ErrorBoundary', () => {
|
describe('ErrorBoundary', () => {
|
||||||
it('renders children when no error occurs', () => {
|
it('renders children when no error occurs', () => {
|
||||||
render(
|
render(
|
||||||
<ErrorBoundary>
|
<LanguageProvider>
|
||||||
<div>Test content</div>
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<div>Test content</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||||
|
|
@ -28,9 +31,11 @@ describe('ErrorBoundary', () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ErrorBoundary>
|
<LanguageProvider>
|
||||||
<ThrowError shouldThrow={true} />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<ThrowError shouldThrow={true} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Slammed')).toBeInTheDocument();
|
expect(screen.getByText('Slammed')).toBeInTheDocument();
|
||||||
|
|
@ -50,9 +55,11 @@ describe('ErrorBoundary', () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ErrorBoundary fallback={customFallback}>
|
<LanguageProvider>
|
||||||
<ThrowError shouldThrow={true} />
|
<ErrorBoundary fallback={customFallback}>
|
||||||
</ErrorBoundary>
|
<ThrowError shouldThrow={true} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Custom error message')).toBeInTheDocument();
|
expect(screen.getByText('Custom error message')).toBeInTheDocument();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue