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:
SkateSpots 2026-10-04 09:44:15 +07:00
parent ef5a30eb5f
commit 3e534c5ffc
10 changed files with 629 additions and 11 deletions

View file

@ -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

28
NIP.md
View file

@ -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

4
package-lock.json generated
View file

@ -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",

View file

@ -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',

View file

@ -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',

57
src/lib/coachOptions.ts Normal file
View 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;
}

View file

@ -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,

View file

@ -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 (
<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() {
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<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 [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(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() {
</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="space-y-2">
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
@ -513,6 +711,50 @@ export default function BecomeCoachPage() {
)}
</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 */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />

View file

@ -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<number | null>(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<string, typeof GraduationCap> = {
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
@ -376,6 +400,144 @@ export default function CoachDetailPage() {
</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) */}
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
<div className="mb-6">

View file

@ -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(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
<LanguageProvider>
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
</LanguageProvider>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
@ -28,9 +31,11 @@ describe('ErrorBoundary', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
<LanguageProvider>
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</LanguageProvider>
);
expect(screen.getByText('Slammed')).toBeInTheDocument();
@ -50,9 +55,11 @@ describe('ErrorBoundary', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={customFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
<LanguageProvider>
<ErrorBoundary fallback={customFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</LanguageProvider>
);
expect(screen.getByText('Custom error message')).toBeInTheDocument();