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}
} +{t('labUi.coachForm.languagesHint')}
+{t('labUi.coachForm.bookingHint')}
+{t('labUi.coachForm.galleryHint')}
+ {photos.length > 0 && ( +{t('labUi.coachForm.videoHint')}
+{t('labUi.detail.selfAttested')}
+