Expand coach credentials with skate roles + a free-text note

- Rename 'Federation coach' -> 'National federation coach'.
- Add 'Coaching qualification' and 'Competition judge'.
- Add a free-form cert_note so a coach can name an exact cert/issuer.

The core safety chips (first aid, CPR, safeguarding, background check,
insured) stay - they're the load-bearing trust signals. cert_note is
displayed under the chips and included in the same self-attested disclaimer.
This commit is contained in:
SkateSpots 2026-10-04 10:35:23 +07:00
parent 2db13612d1
commit 357d042a13
8 changed files with 44 additions and 15 deletions

View file

@ -63,8 +63,9 @@ session with **no other memory of this project**. So:
(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),
first-aid/cpr/safeguarding/background-check/insured/federation/qualification/
judge + free-form `cert_note` — **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`** +

3
NIP.md
View file

@ -2063,7 +2063,8 @@ client-side.
| `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 |
| `cert` | `first-aid`, `cpr`, `safeguarding`, `background-check`, `insured`, `federation`, `qualification`, `judge` | Self-attested credentials / safety |
| `cert_note` | free-form | Optional — names an exact certification / issuer |
| `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 |

View file

@ -2209,7 +2209,11 @@ labUi: {
certSafeguarding: 'Safeguarding',
certBackgroundCheck: 'Background check',
certInsured: 'Insured',
certFederation: 'Federation coach',
certFederation: 'National federation coach',
certQualification: 'Coaching qualification',
certJudge: 'Competition judge',
certNoteLabel: 'Other credentials (optional)',
certNotePlaceholder: 'e.g. Skateboard GB Level 2, 8 years coaching…',
availWeekdays: 'Weekdays',
availWeekends: 'Weekends',
availEvenings: 'Evenings',

View file

@ -2209,7 +2209,11 @@ labUi: {
certSafeguarding: 'Protección infantil',
certBackgroundCheck: 'Antecedentes penales',
certInsured: 'Asegurado',
certFederation: 'Entrenador federativo',
certFederation: 'Entrenador federativo nacional',
certQualification: 'Titulación de entrenador',
certJudge: 'Juez de competición',
certNoteLabel: 'Otras credenciales (opcional)',
certNotePlaceholder: 'p. ej. Nivel 2 de Skateboard GB, 8 años entrenando…',
availWeekdays: 'Entre semana',
availWeekends: 'Fines de semana',
availEvenings: 'Tardes/noches',

View file

@ -40,6 +40,8 @@ export const COACH_CREDENTIALS: CoachOption[] = [
{ value: 'background-check', key: 'labUi.coachForm.certBackgroundCheck' },
{ value: 'insured', key: 'labUi.coachForm.certInsured' },
{ value: 'federation', key: 'labUi.coachForm.certFederation' },
{ value: 'qualification', key: 'labUi.coachForm.certQualification' },
{ value: 'judge', key: 'labUi.coachForm.certJudge' },
];
export const COACH_AVAILABILITY: CoachOption[] = [

View file

@ -2343,6 +2343,8 @@ export interface SkateCoach {
ageGroups: string[];
/** Self-attested credentials (first-aid, safeguarding, …). */
credentials: string[];
/** Optional free-form credential note (names an exact cert / issuer). */
certNote?: string;
/** Languages spoken. */
languages: string[];
/** Availability windows (weekends, evenings, …). */
@ -2692,6 +2694,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
const levels = values('level');
const ageGroups = values('age');
const credentials = values('cert');
const certNote = event.tags.find(([name]) => name === 'cert_note')?.[1];
// Languages are proper nouns — preserve case (no lowercasing).
const languages = event.tags
.filter(([n, v]) => n === 'language' && !!v?.trim())
@ -2753,6 +2756,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
levels,
ageGroups,
credentials,
certNote,
languages,
availability,
booking,

View file

@ -140,6 +140,7 @@ export default function BecomeCoachPage() {
const [levels, setLevels] = useState<string[]>([]);
const [ageGroups, setAgeGroups] = useState<string[]>([]);
const [credentials, setCredentials] = useState<string[]>([]);
const [certNote, setCertNote] = useState('');
const [languages, setLanguages] = useState<string[]>([]);
const [languageInput, setLanguageInput] = useState('');
const [availability, setAvailability] = useState<string[]>([]);
@ -187,6 +188,7 @@ export default function BecomeCoachPage() {
setLevels(tags('level'));
setAgeGroups(tags('age'));
setCredentials(tags('cert'));
setCertNote(tag('cert_note') ?? '');
setLanguages(tags('language'));
setAvailability(tags('availability'));
setBooking(tag('booking') ?? '');
@ -360,6 +362,7 @@ export default function BecomeCoachPage() {
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]);
if (certNote.trim()) tags.push(['cert_note', certNote.trim()]);
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)]);
@ -619,6 +622,11 @@ export default function BecomeCoachPage() {
idPrefix="cert"
/>
<div className="space-y-1.5">
<Label htmlFor="cert-note" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.certNoteLabel')}</Label>
<Input id="cert-note" placeholder={t('labUi.coachForm.certNotePlaceholder')} value={certNote} onChange={(e) => setCertNote(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<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>

View file

@ -425,21 +425,26 @@ 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')) && (
{isCoach && (parr('credentials').length > 0 || pget('certNote') || 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 && (
{(parr('credentials').length > 0 || pget('certNote')) && (
<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>
{parr('credentials').length > 0 && (
<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>
)}
{pget('certNote') && (
<p className="mt-2 text-sm text-gray-300">{pget('certNote')}</p>
)}
<p className="text-xs text-gray-500 mt-1.5">{t('labUi.detail.selfAttested')}</p>
</div>
)}