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
|
||||
`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
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
|
||||
(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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
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[];
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue