From 3e534c5ffcd297fcb618e607164f335caf7afd63 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 4 Oct 2026 09:44:15 +0700 Subject: [PATCH] Add coach portfolio fields and gallery to Lab coach profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- AGENTS.md | 15 ++ NIP.md | 28 ++++ package-lock.json | 4 +- src/i18n/en.ts | 30 ++++ src/i18n/es.ts | 30 ++++ src/lib/coachOptions.ts | 57 ++++++++ src/lib/types.ts | 47 +++++++ src/pages/BecomeCoachPage.tsx | 242 ++++++++++++++++++++++++++++++++ src/pages/CoachDetailPage.tsx | 162 +++++++++++++++++++++ src/test/ErrorBoundary.test.tsx | 25 ++-- 10 files changed, 629 insertions(+), 11 deletions(-) create mode 100644 src/lib/coachOptions.ts diff --git a/AGENTS.md b/AGENTS.md index dd063df..b8a4de9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,21 @@ session with **no other memory of this project**. So: `isValidEmail()`, and website/portfolio must resolve through `sanitizeUrl(normalizeUrlInput(...))`. Invalid input shows an inline hint and 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 image input — **drag & drop + multi-select**, emits `File[]` to its caller (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and diff --git a/NIP.md b/NIP.md index a0ad6c8..97c9cc5 100644 --- a/NIP.md +++ b/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 (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) Skate photography is a distinct craft from filming — it is shot-, light-, and diff --git a/package-lock.json b/package-lock.json index 3c39f0f..ca5e9c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "skate-spots", - "version": "1.5.11", + "version": "1.5.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "skate-spots", - "version": "1.5.11", + "version": "1.5.12", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/android": "^8.4.2", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3e440a7..d93460a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -14,6 +14,8 @@ const en = { cancel: 'Cancel', delete: 'Delete', edit: 'Edit', + add: 'Add', + remove: 'Remove', create: 'Create', submit: 'Submit', close: 'Close', @@ -2167,6 +2169,24 @@ labUi: { selectSkill: 'Select at least one skill.', skillsLabel: 'Skills I Teach * (select at least one)', 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', spotsLabel: 'Spots Where You Coach', tagline: 'Share your skateboarding knowledge', @@ -2470,6 +2490,16 @@ labUi: { eventsOrganized: 'Events Organized', spots: 'Spots', 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', licensedInsured: 'Licensed / Insured', studio: 'Studio', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 38c5b91..2e2e858 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -15,6 +15,8 @@ const es = { cancel: 'Cancelar', delete: 'Eliminar', edit: 'Editar', + add: 'Añadir', + remove: 'Quitar', create: 'Crear', submit: 'Enviar', close: 'Cerrar', @@ -2167,6 +2169,24 @@ labUi: { selectSkill: 'Selecciona al menos un truco.', skillsLabel: 'Trucos que enseño * (selecciona al menos uno)', 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', spotsLabel: 'Spots donde entrenas', tagline: 'Comparte tus conocimientos de skate', @@ -2470,6 +2490,16 @@ labUi: { eventsOrganized: 'Eventos organizados', spots: 'Spots', 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', licensedInsured: 'Con licencia / asegurado', studio: 'Estudio', diff --git a/src/lib/coachOptions.ts b/src/lib/coachOptions.ts new file mode 100644 index 0000000..5acd204 --- /dev/null +++ b/src/lib/coachOptions.ts @@ -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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 91309f0..3dbfff3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2335,6 +2335,24 @@ export interface SkateCoach { skills: string[]; experience?: 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; email?: string; website?: string; @@ -2648,6 +2666,26 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null { 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 !== '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 rate = event.tags.find(([name]) => name === 'rate')?.[1]; const phone = event.tags.find(([name]) => name === 'phone')?.[1]; @@ -2683,6 +2721,15 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null { entityType, members, spotRefs: spotRefs.length > 0 ? spotRefs : undefined, + formats, + levels, + ageGroups, + credentials, + languages, + availability, + booking, + photos, + video, crewRef: parseCrewRef(event), featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 23fc621..596faef 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -18,12 +18,15 @@ import { WalletModal } from '@/components/WalletModal'; import { LoginArea } from '@/components/auth/LoginArea'; import { Nip05Required } from '@/components/Nip05Required'; import { SpotPicker } from '@/components/SpotPicker'; +import { PhotoDropzone } from '@/components/PhotoDropzone'; +import { ImageWithFallback } from '@/components/ImageWithFallback'; import { NofferField } from '@/components/NofferField'; import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; import { EntityTypeField } from '@/components/EntityTypeField'; 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 { 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 { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; @@ -34,6 +37,52 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; 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 ( +
+ + {hint &&

{hint}

} +
+ {options.map((o) => { + const active = selected.includes(o.value); + return ( + + ); + })} +
+
+ ); +} + export default function BecomeCoachPage() { const { t } = useTranslation(); useSeoMeta({ @@ -69,6 +118,16 @@ export default function BecomeCoachPage() { const [website, setWebsite] = useState(''); const [instagram, setInstagram] = useState(''); const [noffer, setNoffer] = useState(''); + const [formats, setFormats] = useState([]); + const [levels, setLevels] = useState([]); + const [ageGroups, setAgeGroups] = useState([]); + const [credentials, setCredentials] = useState([]); + const [languages, setLanguages] = useState([]); + const [languageInput, setLanguageInput] = useState(''); + const [availability, setAvailability] = useState([]); + const [booking, setBooking] = useState(''); + const [photos, setPhotos] = useState([]); + const [video, setVideo] = useState(''); const [entityType, setEntityType] = useState('individual'); const [members, setMembers] = useState([]); const [crewRef, setCrewRef] = useState(undefined); @@ -98,6 +157,15 @@ export default function BecomeCoachPage() { setWebsite(tag('website') ?? ''); setInstagram(tag('instagram') ?? ''); 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'); setMembers(tags('p')); 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]); }; + 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 = () => { if (!navigator.geolocation) { toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' }); @@ -164,6 +243,25 @@ export default function BecomeCoachPage() { 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 // be stored and then silently hidden. const contactError = email.trim() && !isValidEmail(email) @@ -220,6 +318,16 @@ export default function BecomeCoachPage() { if (instagram.trim()) tags.push(['instagram', instagram.trim()]); 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]); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); @@ -426,6 +534,96 @@ export default function BecomeCoachPage() { + toggleValue(formats, setFormats, v)} + idPrefix="format" + /> + + toggleValue(levels, setLevels, v)} + idPrefix="level" + /> + + toggleValue(ageGroups, setAgeGroups, v)} + idPrefix="age" + /> + + toggleValue(credentials, setCredentials, v)} + idPrefix="cert" + /> + +
+ +

{t('labUi.coachForm.languagesHint')}

+
+ 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" + /> + +
+ {languages.length > 0 && ( +
+ {languages.map((lang) => ( + + {lang} + + + ))} +
+ )} +
+ + toggleValue(availability, setAvailability, v)} + idPrefix="availability" + /> + +
+ + setBooking(e.target.value)} + placeholder="https://cal.com/yourname" + className="bg-gray-800 border-gray-700" + /> +

{t('labUi.coachForm.bookingHint')}

+
+
@@ -513,6 +711,50 @@ export default function BecomeCoachPage() { )}
+
+ +

{t('labUi.coachForm.galleryHint')}

+ {photos.length > 0 && ( +
+ {photos.map((url) => ( +
+ + +
+ ))} +
+ )} + {photos.length < 12 && ( + + )} +
+ +
+ + setVideo(e.target.value)} + placeholder="https://youtube.com/watch?v=…" + className="bg-gray-800 border-gray-700" + /> +

{t('labUi.coachForm.videoHint')}

+
+ {/* Optional Pro upgrade — free to list without it */} diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index c2950b1..736ca22 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -1,7 +1,11 @@ import { dateLocaleCode } from '@/lib/dateFnsI18n'; +import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link, useNavigate } from 'react-router-dom'; 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 { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -37,6 +41,7 @@ import { ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS, BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS, } 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 { useTranslation } from '@/contexts/language'; import { OfficialBadge } from '@/components/OfficialBadge'; @@ -66,6 +71,12 @@ import { Gavel, Tent, Sparkles, + ShieldCheck, + Languages as LanguagesIcon, + CalendarClock, + Images, + Clapperboard, + ExternalLink, } from 'lucide-react'; import { nip19 } from 'nostr-tools'; @@ -105,6 +116,7 @@ export default function CoachDetailPage() { const author = useAuthor(profile?.pubkey); const { user } = useCurrentUser(); const navigate = useNavigate(); + const [lightboxIndex, setLightboxIndex] = useState(null); // Gigs — only relevant for musicians. The hook is disabled for other types. const { data: gigs = [] } = useGigs( @@ -175,6 +187,18 @@ export default function CoachDetailPage() { return typeof v === 'string' && v.length > 0 ? v : undefined; }; 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 = { coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors, @@ -376,6 +400,144 @@ export default function CoachDetailPage() {
)} + {/* 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')) && ( +
+ {parr('credentials').length > 0 && ( +
+

+ {t('labUi.detail.credentialsTitle')} +

+
+ {parr('credentials').map((c) => ( + + + {optLabel(COACH_CREDENTIALS, c)} + + ))} +
+

{t('labUi.detail.selfAttested')}

+
+ )} + + {(parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0) && ( +
+ {parr('formats').length > 0 && ( +
+

{t('labUi.detail.formats')}

+
+ {parr('formats').map((v) => ( + {optLabel(COACH_FORMATS, v)} + ))} +
+
+ )} + {parr('levels').length > 0 && ( +
+

{t('labUi.detail.levels')}

+
+ {parr('levels').map((v) => ( + {optLabel(COACH_LEVELS, v)} + ))} +
+
+ )} + {parr('ageGroups').length > 0 && ( +
+

{t('labUi.detail.ages')}

+
+ {parr('ageGroups').map((v) => ( + {optLabel(COACH_AGE_GROUPS, v)} + ))} +
+
+ )} +
+ )} + + {parr('languages').length > 0 && ( +
+

+ {t('labUi.detail.languages')} +

+
+ {parr('languages').map((l) => ( + {l} + ))} +
+
+ )} + + {(parr('availability').length > 0 || pget('booking')) && ( +
+

+ {t('labUi.detail.availability')} +

+ {parr('availability').length > 0 && ( +
+ {parr('availability').map((v) => ( + {optLabel(COACH_AVAILABILITY, v)} + ))} +
+ )} + {sanitizeUrl(pget('booking')) && ( + + + {t('labUi.detail.bookSession')} + + + )} +
+ )} +
+ )} + + {/* Coach — portfolio gallery & highlight reel */} + {isCoach && (galleryImages.length > 0 || videoEmbed) && ( +
+

+ {t('labUi.detail.portfolio')} +

+ {galleryImages.length > 0 && ( +
+ {galleryImages.map((img, i) => ( + + ))} +
+ )} + {videoEmbed && ( + + + {t('labUi.detail.watchReel', { platform: videoEmbed.source === 'youtube' ? 'YouTube' : 'Instagram' })} + + + )} + setLightboxIndex(null)} + /> +
+ )} + {/* In the Bag — photographer / filmer gear (freeform) */} {(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
diff --git a/src/test/ErrorBoundary.test.tsx b/src/test/ErrorBoundary.test.tsx index 7e3634f..ae8c706 100644 --- a/src/test/ErrorBoundary.test.tsx +++ b/src/test/ErrorBoundary.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { LanguageProvider } from '@/contexts/LanguageContext'; @@ -15,9 +16,11 @@ const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => { describe('ErrorBoundary', () => { it('renders children when no error occurs', () => { render( - -
Test content
-
+ + +
Test content
+
+
); expect(screen.getByText('Test content')).toBeInTheDocument(); @@ -28,9 +31,11 @@ describe('ErrorBoundary', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); render( - - - + + + + + ); expect(screen.getByText('Slammed')).toBeInTheDocument(); @@ -50,9 +55,11 @@ describe('ErrorBoundary', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); render( - - - + + + + + ); expect(screen.getByText('Custom error message')).toBeInTheDocument();