Fix raw i18n keys on coach chips; add structured rate + form polish

The coach option chips rendered raw 'labUi.coachForm.…' keys because the 29
per-option translation keys were referenced dynamically (t(option.key)) and had
never been added — the literal-only i18n guard missed them. Add every option
key (EN/ES) and extend scripts/check-i18n.mjs to also scan data tables for
key/titleKey/labelKey/badgeKey entries so this can't recur.

Also fix the chip layout: the global mobile 'button { min-height:44px }' rule
turned every chip into a 44px slab; add min-h-0 to the coach option chips,
language chips, Lab facet chips and Lab active-filter chips.

Remaining nice-to-haves:
- Structured rate: rate_amount + rate_currency (sats/USD/EUR/GBP) + rate_unit
  (hour/session/half-day/day/camp), rendered on the detail page; legacy
  freeform 'rate' still shows and acts as an optional note.
- Section the long coach form with a FormSection divider.
- Restyle the coach identity block from a red error-looking box to a neutral
  avatar card.
This commit is contained in:
SkateSpots 2026-10-04 10:05:11 +07:00
parent 468175a0fa
commit fa0b9cfe1c
10 changed files with 275 additions and 33 deletions

View file

@ -79,6 +79,13 @@ session with **no other memory of this project**. So:
toolbar and count toward `activeFilterCount`. Client-side only (these tags are toolbar and count toward `activeFilterCount`. Client-side only (these tags are
not relay-indexed). Testimonials are already covered by the existing Lab not relay-indexed). Testimonials are already covered by the existing Lab
scene-vouch system (`VouchCard`, kind 1985) — no new kind needed. scene-vouch system (`VouchCard`, kind 1985) — no new kind needed.
**Structured rate + form polish (2026-10-04):** `rate_amount`/`rate_currency`
(sats/USD/EUR/GBP) × `rate_unit` (hour/session/half-day/day/camp) replace the
free-text-only rate; the legacy `rate` still renders and acts as an optional
note. The form is grouped with a `FormSection` divider, the identity block is
a neutral avatar card (it was a red error-looking box), and chips use
**`min-h-0`** — the global mobile `button { min-height:44px }` rule turned
every chip into a 44px slab otherwise.
- **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared - **Photos**: `PhotoDropzone` (`src/components/PhotoDropzone.tsx`) is the shared
image input — **drag & drop + multi-select**, emits `File[]` to its caller image input — **drag & drop + multi-select**, emits `File[]` to its caller
(which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and (which owns uploads/toasts). Used by `CreateSpotPage`, `EditSpotPage` and
@ -1815,8 +1822,13 @@ similar are internal endpoints/hook names, not credits — leave them.)
is whatever the author wrote). is whatever the author wrote).
- **Safety net:** `scripts/check-i18n.mjs` (wired into `npm test` as part of the - **Safety net:** `scripts/check-i18n.mjs` (wired into `npm test` as part of the
chain) fails the build if any literal `t('…')` key is missing from `en` OR `es`, chain) fails the build if any literal `t('…')` key is missing from `en` OR `es`,
or if `en`/`es` drift. Dynamic template keys (`t(\`ns.${x}\`)`) are NOT checked — or if `en`/`es` drift. It **also scans data tables** for keys declared as
keep those few in sync by hand. `key:`/`titleKey:`/`labelKey:`/`badgeKey:` (e.g. `src/lib/coachOptions.ts`),
because those are looked up dynamically via `t(option.key)` and a plain literal
scan misses them — that gap shipped raw `labUi.…` keys to the UI once
(fix 2026-10-04: 29 option keys were referenced but never translated). Still
unchecked: template keys built by interpolation (`t(\`ns.${x}\`)`) — keep those
few in sync by hand.
- **Coverage (2026-10-04, batches 1–4 done):** **~7,030 keys** (EN=ES) and ~250 - **Coverage (2026-10-04, batches 1–4 done):** **~7,030 keys** (EN=ES) and ~250
files wired. Namespaces: batch 1 — `mapUi`, `spotUi`, `marketUi`, `labUi`; files wired. Namespaces: batch 1 — `mapUi`, `spotUi`, `marketUi`, `labUi`;
batch 2 — `nav` (header/More/burger labels) + `chromeUi` (footer, toolbar batch 2 — `nav` (header/More/burger labels) + `chromeUi` (footer, toolbar

6
NIP.md
View file

@ -2069,6 +2069,12 @@ client-side.
| `booking` | URL | Optional booking / scheduling link | | `booking` | URL | Optional booking / scheduling link |
| `photo` | URL | Repeatable — portfolio gallery image (max 12) | | `photo` | URL | Repeatable — portfolio gallery image (max 12) |
| `video` | URL | Optional highlight-reel link (YouTube / Instagram) | | `video` | URL | Optional highlight-reel link (YouTube / Instagram) |
| `rate_amount` | number | Optional structured rate amount (with `rate_currency` + `rate_unit`) |
| `rate_currency` | `sats`, `USD`, `EUR`, `GBP` | Currency for `rate_amount` |
| `rate_unit` | `hour`, `session`, `half-day`, `day`, `camp` | Billing unit for `rate_amount` |
The legacy freeform `rate` tag is still supported and shown when no structured
rate is present; when both exist, `rate` is treated as a note beneath the price.
The Lab's coaching filter drawer offers these as facet filters (formats, levels, The Lab's coaching filter drawer offers these as facet filters (formats, levels,
ages, credentials, availability, languages), populated only from values present ages, credentials, availability, languages), populated only from values present

View file

@ -73,14 +73,23 @@ function walk(dir) {
/** Collect every `t('some.key')` usage across the source. */ /** Collect every `t('some.key')` usage across the source. */
function collectUsedKeys(files) { function collectUsedKeys(files) {
const used = new Map(); // key -> [files] const used = new Map(); // key -> [files]
const re = /(?<![\w.])t\(\s*['"]([^'"]+)['"]/g; const tRe = /(?<![\w.])t\(\s*['"]([^'"]+)['"]/g;
// Also pick up translation keys declared in data tables, e.g.
// `{ value: 'group', key: 'labUi.coachForm.formatGroup' }` or
// `titleKey:` / `labelKey:` / `badgeKey:`. These are looked up dynamically via
// `t(option.key)`, so the literal-`t()` scan alone would miss them.
const dataRe = /\b(?:key|titleKey|labelKey|badgeKey):\s*['"]([a-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)['"]/g;
for (const file of files) { for (const file of files) {
const text = readFileSync(file, 'utf8'); const text = readFileSync(file, 'utf8');
let match; const rel = file.replace(`${ROOT}/`, '');
while ((match = re.exec(text)) !== null) { for (const re of [tRe, dataRe]) {
const key = match[1]; re.lastIndex = 0;
if (!used.has(key)) used.set(key, []); let match;
used.get(key).push(file.replace(`${ROOT}/`, '')); while ((match = re.exec(text)) !== null) {
const key = match[1];
if (!used.has(key)) used.set(key, []);
used.get(key).push(rel);
}
} }
} }
return used; return used;

View file

@ -2187,6 +2187,52 @@ labUi: {
galleryUploaded: '{{count}} photo(s) uploaded', galleryUploaded: '{{count}} photo(s) uploaded',
videoLabel: 'Highlight reel', videoLabel: 'Highlight reel',
videoHint: 'Optional — a YouTube or Instagram video of your coaching.', videoHint: 'Optional — a YouTube or Instagram video of your coaching.',
formatOneOnOne: '1-on-1',
formatGroup: 'Group',
formatCamp: 'Camp',
formatVideo: 'Video analysis',
formatContestPrep: 'Contest prep',
formatParkLesson: 'Park lesson',
formatAdaptive: 'Adaptive',
formatGirls: 'Girls session',
formatAfterSchool: 'After school',
levelBeginner: 'Beginner',
levelIntermediate: 'Intermediate',
levelAdvanced: 'Advanced',
levelCompetition: 'Competition',
ageKids: 'Kids',
ageTeens: 'Teens',
ageAdults: 'Adults',
ageSeniors: 'Seniors',
certFirstAid: 'First aid',
certCpr: 'CPR',
certSafeguarding: 'Safeguarding',
certBackgroundCheck: 'Background check',
certInsured: 'Insured',
certFederation: 'Federation coach',
availWeekdays: 'Weekdays',
availWeekends: 'Weekends',
availEvenings: 'Evenings',
availAfterSchool: 'After school',
availHolidays: 'School holidays',
availSeasonal: 'Seasonal',
sectionWhatYouCoach: 'What you coach',
sectionCredentials: 'Credentials & safety',
sectionAvailability: 'Availability & booking',
sectionWhereYouCoach: 'Where you coach',
sectionPortfolio: 'Portfolio',
rateAmountLabel: 'Rate',
rateCurrencySats: 'sats',
rateCurrencyUsd: 'USD',
rateCurrencyEur: 'EUR',
rateCurrencyGbp: 'GBP',
perHour: 'per hour',
perSession: 'per session',
perHalfDay: 'per half-day',
perDay: 'per day',
perCamp: 'per camp',
rateNotesLabel: 'Rate notes (optional)',
rateNotesPlaceholder: 'Sliding scale, first session free, travel included…',
spotsDesc: 'Select spots where you offer coaching sessions', spotsDesc: 'Select spots where you offer coaching sessions',
spotsLabel: 'Spots Where You Coach', spotsLabel: 'Spots Where You Coach',
tagline: 'Share your skateboarding knowledge', tagline: 'Share your skateboarding knowledge',

View file

@ -2187,6 +2187,52 @@ labUi: {
galleryUploaded: '{{count}} foto(s) subida(s)', galleryUploaded: '{{count}} foto(s) subida(s)',
videoLabel: 'Vídeo destacado', videoLabel: 'Vídeo destacado',
videoHint: 'Opcional — un vídeo de YouTube o Instagram de tus clases.', videoHint: 'Opcional — un vídeo de YouTube o Instagram de tus clases.',
formatOneOnOne: '1 a 1',
formatGroup: 'Grupo',
formatCamp: 'Campus',
formatVideo: 'Análisis de vídeo',
formatContestPrep: 'Preparación de competición',
formatParkLesson: 'Clase en el park',
formatAdaptive: 'Adaptado',
formatGirls: 'Sesión de chicas',
formatAfterSchool: 'Extraescolar',
levelBeginner: 'Principiante',
levelIntermediate: 'Intermedio',
levelAdvanced: 'Avanzado',
levelCompetition: 'Competición',
ageKids: 'Niños',
ageTeens: 'Adolescentes',
ageAdults: 'Adultos',
ageSeniors: 'Mayores',
certFirstAid: 'Primeros auxilios',
certCpr: 'RCP',
certSafeguarding: 'Protección infantil',
certBackgroundCheck: 'Antecedentes penales',
certInsured: 'Asegurado',
certFederation: 'Entrenador federativo',
availWeekdays: 'Entre semana',
availWeekends: 'Fines de semana',
availEvenings: 'Tardes/noches',
availAfterSchool: 'Extraescolar',
availHolidays: 'Vacaciones escolares',
availSeasonal: 'Temporada',
sectionWhatYouCoach: 'Qué enseñas',
sectionCredentials: 'Credenciales y seguridad',
sectionAvailability: 'Disponibilidad y reservas',
sectionWhereYouCoach: 'Dónde entrenas',
sectionPortfolio: 'Portfolio',
rateAmountLabel: 'Tarifa',
rateCurrencySats: 'sats',
rateCurrencyUsd: 'USD',
rateCurrencyEur: 'EUR',
rateCurrencyGbp: 'GBP',
perHour: 'por hora',
perSession: 'por sesión',
perHalfDay: 'por media jornada',
perDay: 'por día',
perCamp: 'por campus',
rateNotesLabel: 'Notas sobre la tarifa (opcional)',
rateNotesPlaceholder: 'Tarifa social, primera clase gratis, desplazamiento incluido…',
spotsDesc: 'Selecciona los spots donde ofreces clases', spotsDesc: 'Selecciona los spots donde ofreces clases',
spotsLabel: 'Spots donde entrenas', spotsLabel: 'Spots donde entrenas',
tagline: 'Comparte tus conocimientos de skate', tagline: 'Comparte tus conocimientos de skate',

View file

@ -72,3 +72,31 @@ export const COACH_FACETS: { key: CoachFacetKey; titleKey: string; options: Coac
export function emptyCoachFilters(): Record<CoachFacetKey, string[]> { export function emptyCoachFilters(): Record<CoachFacetKey, string[]> {
return { formats: [], levels: [], ageGroups: [], credentials: [], availability: [], languages: [] }; return { formats: [], levels: [], ageGroups: [], credentials: [], availability: [], languages: [] };
} }
/** Currencies a coach can price in (sats first — it's a Bitcoin app). */
export const COACH_RATE_CURRENCIES = [
{ value: 'sats', key: 'labUi.coachForm.rateCurrencySats' },
{ value: 'USD', key: 'labUi.coachForm.rateCurrencyUsd' },
{ value: 'EUR', key: 'labUi.coachForm.rateCurrencyEur' },
{ value: 'GBP', key: 'labUi.coachForm.rateCurrencyGbp' },
] as const;
/** Billing units for a structured rate. */
export const COACH_RATE_UNITS = [
{ value: 'hour', key: 'labUi.coachForm.perHour' },
{ value: 'session', key: 'labUi.coachForm.perSession' },
{ value: 'half-day', key: 'labUi.coachForm.perHalfDay' },
{ value: 'day', key: 'labUi.coachForm.perDay' },
{ value: 'camp', key: 'labUi.coachForm.perCamp' },
] as const;
export type CoachRateCurrency = (typeof COACH_RATE_CURRENCIES)[number]['value'];
export type CoachRateUnit = (typeof COACH_RATE_UNITS)[number]['value'];
export function coachRateUnitKey(unit: string | undefined): string | undefined {
return COACH_RATE_UNITS.find((u) => u.value === unit)?.key;
}
export function coachRateCurrencyKey(currency: string | undefined): string | undefined {
return COACH_RATE_CURRENCIES.find((c) => c.value === currency)?.key;
}

View file

@ -2353,6 +2353,12 @@ export interface SkateCoach {
photos: string[]; photos: string[];
/** Optional highlight-reel video URL (YouTube / Instagram). */ /** Optional highlight-reel video URL (YouTube / Instagram). */
video?: string; video?: string;
/** Structured rate amount (string to preserve formatting), optional. */
rateAmount?: string;
/** Structured rate currency (`sats` / `USD` / `EUR` / `GBP`). */
rateCurrency?: string;
/** Structured rate billing unit (`hour` / `session` / …). */
rateUnit?: string;
phone?: string; phone?: string;
email?: string; email?: string;
website?: string; website?: string;
@ -2690,6 +2696,9 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
.filter((v, i, arr) => arr.indexOf(v) === i) .filter((v, i, arr) => arr.indexOf(v) === i)
.slice(0, 12); .slice(0, 12);
const video = event.tags.find(([name]) => name === 'video')?.[1]; const video = event.tags.find(([name]) => name === 'video')?.[1];
const rateAmount = event.tags.find(([name]) => name === 'rate_amount')?.[1];
const rateCurrency = event.tags.find(([name]) => name === 'rate_currency')?.[1];
const rateUnit = event.tags.find(([name]) => name === 'rate_unit')?.[1];
const experience = event.tags.find(([name]) => name === 'experience')?.[1]; const experience = event.tags.find(([name]) => name === 'experience')?.[1];
const rate = event.tags.find(([name]) => name === 'rate')?.[1]; const rate = event.tags.find(([name]) => name === 'rate')?.[1];
const phone = event.tags.find(([name]) => name === 'phone')?.[1]; const phone = event.tags.find(([name]) => name === 'phone')?.[1];
@ -2734,6 +2743,9 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
booking, booking,
photos, photos,
video, video,
rateAmount,
rateCurrency,
rateUnit,
crewRef: parseCrewRef(event), crewRef: parseCrewRef(event),
featuredUntil: parseFeaturedUntil(event), featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at, createdAt: event.created_at,

View file

@ -26,7 +26,7 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; 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 { 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_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 { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, COACH_RATE_CURRENCIES, COACH_RATE_UNITS, type CoachOption } from '@/lib/coachOptions';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -37,6 +37,16 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS); const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
/** Section divider/heading inside the (long) coach form. */
function FormSection({ title, hint }: { title: string; hint?: string }) {
return (
<div className="pt-4 border-t border-gray-800">
<h3 className="text-sm font-black uppercase tracking-widest text-red-400">{title}</h3>
{hint && <p className="text-xs text-gray-500 mt-0.5">{hint}</p>}
</div>
);
}
/** Reusable chip toggle row for the curated coach option sets. */ /** Reusable chip toggle row for the curated coach option sets. */
function OptionChips({ function OptionChips({
label, label,
@ -56,9 +66,9 @@ function OptionChips({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label>{label}</Label> {label && <Label>{label}</Label>}
{hint && <p className="text-xs text-gray-500">{hint}</p>} {hint && <p className="text-xs text-gray-500">{hint}</p>}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{options.map((o) => { {options.map((o) => {
const active = selected.includes(o.value); const active = selected.includes(o.value);
return ( return (
@ -68,10 +78,10 @@ function OptionChips({
onClick={() => onToggle(o.value)} onClick={() => onToggle(o.value)}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'px-3 py-1.5 rounded-full border text-sm transition-colors', 'min-h-0 min-w-0 leading-none px-3 py-2 rounded-full border text-xs font-bold transition-colors active:scale-95',
active active
? 'bg-red-500 text-white border-red-500' ? 'bg-red-500 text-white border-red-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-200', : 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300',
)} )}
> >
{t(o.key)} {t(o.key)}
@ -113,6 +123,9 @@ export default function BecomeCoachPage() {
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState(''); const [experience, setExperience] = useState('');
const [rate, setRate] = useState(''); const [rate, setRate] = useState('');
const [rateAmount, setRateAmount] = useState('');
const [rateCurrency, setRateCurrency] = useState<string>('sats');
const [rateUnit, setRateUnit] = useState<string>('hour');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [website, setWebsite] = useState(''); const [website, setWebsite] = useState('');
@ -152,6 +165,9 @@ export default function BecomeCoachPage() {
setImage(tag('image') ?? null); setImage(tag('image') ?? null);
setExperience(tag('experience') ?? ''); setExperience(tag('experience') ?? '');
setRate(tag('rate') ?? ''); setRate(tag('rate') ?? '');
setRateAmount(tag('rate_amount') ?? '');
setRateCurrency(tag('rate_currency') ?? 'sats');
setRateUnit(tag('rate_unit') ?? 'hour');
setPhone(tag('phone') ?? ''); setPhone(tag('phone') ?? '');
setEmail(tag('email') ?? ''); setEmail(tag('email') ?? '');
setWebsite(tag('website') ?? ''); setWebsite(tag('website') ?? '');
@ -312,6 +328,11 @@ export default function BecomeCoachPage() {
if (image) tags.push(['image', image]); if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]); if (experience.trim()) tags.push(['experience', experience.trim()]);
if (rate.trim()) tags.push(['rate', rate.trim()]); if (rate.trim()) tags.push(['rate', rate.trim()]);
if (rateAmount.trim()) {
tags.push(['rate_amount', rateAmount.trim()]);
tags.push(['rate_currency', rateCurrency]);
tags.push(['rate_unit', rateUnit]);
}
if (phone.trim()) tags.push(['phone', phone.trim()]); if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]); if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]); if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
@ -470,9 +491,18 @@ export default function BecomeCoachPage() {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('labUi.coachForm.identity')}</Label> <Label>{t('labUi.coachForm.identity')}</Label>
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5"> <div className="flex items-center gap-3 p-4 rounded-lg border border-gray-700 bg-gray-800/50">
<p className="font-black text-lg">{getDisplayName()}</p> <div className="h-12 w-12 rounded-full overflow-hidden bg-gray-700 flex items-center justify-center flex-shrink-0">
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p> {metadata?.picture ? (
<img src={metadata.picture} alt="" className="h-full w-full object-cover" />
) : (
<GraduationCap className="h-6 w-6 text-gray-500" />
)}
</div>
<div className="min-w-0">
<p className="font-black text-lg truncate">{getDisplayName()}</p>
<p className="text-sm text-gray-400">{t('labUi.become.fromNostrProfile')}</p>
</div>
</div> </div>
</div> </div>
@ -510,6 +540,8 @@ export default function BecomeCoachPage() {
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90" accentText="text-[#ff0040]" /> <CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90" accentText="text-[#ff0040]" />
)} )}
<FormSection title={t('labUi.coachForm.sectionWhatYouCoach')} />
<div className="space-y-3"> <div className="space-y-3">
<Label>{t('labUi.coachForm.skillsLabel')}</Label> <Label>{t('labUi.coachForm.skillsLabel')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
@ -559,8 +591,10 @@ export default function BecomeCoachPage() {
idPrefix="age" idPrefix="age"
/> />
<FormSection title={t('labUi.coachForm.sectionCredentials')} />
<OptionChips <OptionChips
label={t('labUi.coachForm.credentialsLabel')} label=""
hint={t('labUi.coachForm.credentialsHint')} hint={t('labUi.coachForm.credentialsHint')}
options={COACH_CREDENTIALS} options={COACH_CREDENTIALS}
selected={credentials} selected={credentials}
@ -587,13 +621,13 @@ export default function BecomeCoachPage() {
{languages.length > 0 && ( {languages.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1"> <div className="flex flex-wrap gap-2 pt-1">
{languages.map((lang) => ( {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"> <span key={lang} className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 py-1 pl-3 pr-2 text-xs font-bold text-gray-300">
{lang} {lang}
<button <button
type="button" type="button"
onClick={() => setLanguages((prev) => prev.filter((l) => l !== lang))} onClick={() => setLanguages((prev) => prev.filter((l) => l !== lang))}
aria-label={`${t('common.remove')} ${lang}`} aria-label={`${t('common.remove')} ${lang}`}
className="text-gray-400 hover:text-white" className="min-h-0 min-w-0 text-gray-500 hover:text-white"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@ -603,8 +637,10 @@ export default function BecomeCoachPage() {
)} )}
</div> </div>
<FormSection title={t('labUi.coachForm.sectionAvailability')} />
<OptionChips <OptionChips
label={t('labUi.coachForm.availabilityLabel')} label=""
options={COACH_AVAILABILITY} options={COACH_AVAILABILITY}
selected={availability} selected={availability}
onToggle={(v) => toggleValue(availability, setAvailability, v)} onToggle={(v) => toggleValue(availability, setAvailability, v)}
@ -624,17 +660,51 @@ export default function BecomeCoachPage() {
<p className="text-xs text-gray-500">{t('labUi.coachForm.bookingHint')}</p> <p className="text-xs text-gray-500">{t('labUi.coachForm.bookingHint')}</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label> <Input id="experience" type="number" placeholder="15" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input id="experience" type="number" placeholder="15" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" /> </div>
<div className="space-y-3">
<Label>{t('labUi.coachForm.rateAmountLabel')}</Label>
<div className="grid grid-cols-3 gap-2">
<Input
type="number"
inputMode="numeric"
placeholder="5000"
value={rateAmount}
onChange={(e) => setRateAmount(e.target.value.replace(/[^0-9.]/g, ''))}
className="bg-gray-800 border-gray-700"
/>
<select
value={rateCurrency}
onChange={(e) => setRateCurrency(e.target.value)}
aria-label={t('labUi.coachForm.rateAmountLabel')}
className="h-10 min-w-0 rounded-md border border-gray-700 bg-gray-800 px-2 text-sm text-white"
>
{COACH_RATE_CURRENCIES.map((c) => (
<option key={c.value} value={c.value}>{t(c.key)}</option>
))}
</select>
<select
value={rateUnit}
onChange={(e) => setRateUnit(e.target.value)}
aria-label={t('labUi.coachForm.rateAmountLabel')}
className="h-10 min-w-0 rounded-md border border-gray-700 bg-gray-800 px-2 text-sm text-white"
>
{COACH_RATE_UNITS.map((u) => (
<option key={u.value} value={u.value}>{t(u.key)}</option>
))}
</select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<Label htmlFor="rate">{t('labUi.coachForm.hourlyRate')}</Label> <Label htmlFor="rate" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.rateNotesLabel')}</Label>
<Input id="rate" placeholder={t('labUi.coachForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" /> <Input id="rate" placeholder={t('labUi.coachForm.rateNotesPlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div> </div>
</div> </div>
<FormSection title={t('labUi.coachForm.sectionWhereYouCoach')} />
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="location">{t('labUi.become.cityArea')}</Label> <Label htmlFor="location">{t('labUi.become.cityArea')}</Label>
<Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" /> <Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
@ -694,6 +764,8 @@ export default function BecomeCoachPage() {
</Button> </Button>
</div> </div>
<FormSection title={t('labUi.coachForm.sectionPortfolio')} />
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('labUi.become.profilePhoto')}</Label> <Label>{t('labUi.become.profilePhoto')}</Label>
{image ? ( {image ? (

View file

@ -41,7 +41,7 @@ import {
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS, ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS, BARBER_SERVICE_KEYS, BARBER_SERVICE_DESC_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
} from '@/lib/labelKeys'; } from '@/lib/labelKeys';
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, coachOptionKey, type CoachOption } from '@/lib/coachOptions'; import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { OfficialBadge } from '@/components/OfficialBadge'; import { OfficialBadge } from '@/components/OfficialBadge';
@ -199,6 +199,14 @@ export default function CoachDetailPage() {
}; };
const galleryImages = parr('photos').map((url) => ({ url })); const galleryImages = parr('photos').map((url) => ({ url }));
const videoEmbed = parseCrewVideo(pget('video')); const videoEmbed = parseCrewVideo(pget('video'));
// Structured rate (`rate_amount`/`rate_currency`/`rate_unit`) with a fallback
// to the legacy freeform `rate` string.
const rateAmount = pget('rateAmount');
const rateCurrencyKey = coachRateCurrencyKey(pget('rateCurrency'));
const rateUnitKey = coachRateUnitKey(pget('rateUnit'));
const rateDisplay = rateAmount
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (pget('rateCurrency') ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
: pget('rate');
const TYPE_ICONS: Record<string, typeof GraduationCap> = { const TYPE_ICONS: Record<string, typeof GraduationCap> = {
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors, coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
@ -784,10 +792,13 @@ export default function CoachDetailPage() {
<p className="text-gray-300">{t('labUi.detail.yearsCount', { count: profile.experience })}</p> <p className="text-gray-300">{t('labUi.detail.yearsCount', { count: profile.experience })}</p>
</div> </div>
)} )}
{profile.rate && ( {rateDisplay && (
<div> <div>
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.rate')}</h4> <h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">{t('labUi.detail.rate')}</h4>
<p className="text-gray-300">{profile.rate}</p> <p className="text-gray-300">{rateDisplay}</p>
{rateAmount && pget('rate') && (
<p className="mt-0.5 text-xs text-gray-500">{pget('rate')}</p>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -685,7 +685,7 @@ function LabToolbar({
key={key} key={key}
onClick={() => onRemoveSkill(key)} onClick={() => onRemoveSkill(key)}
className={cn( className={cn(
'flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95', 'flex items-center gap-1 min-h-0 min-w-0 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95',
accent.chip accent.chip
)} )}
> >
@ -700,7 +700,7 @@ function LabToolbar({
key={key} key={key}
onClick={() => onRemoveCoachFacet(facet, value)} onClick={() => onRemoveCoachFacet(facet, value)}
className={cn( className={cn(
'flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95', 'flex items-center gap-1 min-h-0 min-w-0 px-2.5 py-1 rounded-full text-[10px] font-bold border transition-all hover:opacity-80 active:scale-95',
accent.chip accent.chip
)} )}
> >
@ -989,7 +989,7 @@ function FilterDrawer({
key={value} key={value}
onClick={() => onToggleCoachFacet(key, value)} onClick={() => onToggleCoachFacet(key, value)}
className={cn( className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95', 'min-h-0 min-w-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
active active
? accent.chip ? accent.chip
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}` : `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`