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
not relay-indexed). Testimonials are already covered by the existing Lab
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
image input — **drag & drop + multi-select**, emits `File[]` to its caller
(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).
- **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`,
or if `en`/`es` drift. Dynamic template keys (`t(\`ns.${x}\`)`) are NOT checked —
keep those few in sync by hand.
or if `en`/`es` drift. It **also scans data tables** for keys declared as
`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
files wired. Namespaces: batch 1 — `mapUi`, `spotUi`, `marketUi`, `labUi`;
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 |
| `photo` | URL | Repeatable — portfolio gallery image (max 12) |
| `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,
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. */
function collectUsedKeys(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) {
const text = readFileSync(file, 'utf8');
let match;
while ((match = re.exec(text)) !== null) {
const key = match[1];
if (!used.has(key)) used.set(key, []);
used.get(key).push(file.replace(`${ROOT}/`, ''));
const rel = file.replace(`${ROOT}/`, '');
for (const re of [tRe, dataRe]) {
re.lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const key = match[1];
if (!used.has(key)) used.set(key, []);
used.get(key).push(rel);
}
}
}
return used;

View file

@ -2187,6 +2187,52 @@ labUi: {
galleryUploaded: '{{count}} photo(s) uploaded',
videoLabel: 'Highlight reel',
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',
spotsLabel: 'Spots Where You Coach',
tagline: 'Share your skateboarding knowledge',

View file

@ -2187,6 +2187,52 @@ labUi: {
galleryUploaded: '{{count}} foto(s) subida(s)',
videoLabel: 'Vídeo destacado',
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',
spotsLabel: 'Spots donde entrenas',
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[]> {
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[];
/** Optional highlight-reel video URL (YouTube / Instagram). */
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;
email?: string;
website?: string;
@ -2690,6 +2696,9 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
.filter((v, i, arr) => arr.indexOf(v) === i)
.slice(0, 12);
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 rate = event.tags.find(([name]) => name === 'rate')?.[1];
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
@ -2734,6 +2743,9 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
booking,
photos,
video,
rateAmount,
rateCurrency,
rateUnit,
crewRef: parseCrewRef(event),
featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,

View file

@ -26,7 +26,7 @@ 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 { 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 { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
@ -37,6 +37,16 @@ import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
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. */
function OptionChips({
label,
@ -56,9 +66,9 @@ function OptionChips({
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label>{label}</Label>
{label && <Label>{label}</Label>}
{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) => {
const active = selected.includes(o.value);
return (
@ -68,10 +78,10 @@ function OptionChips({
onClick={() => onToggle(o.value)}
aria-pressed={active}
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
? '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)}
@ -113,6 +123,9 @@ export default function BecomeCoachPage() {
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = 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 [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
@ -152,6 +165,9 @@ export default function BecomeCoachPage() {
setImage(tag('image') ?? null);
setExperience(tag('experience') ?? '');
setRate(tag('rate') ?? '');
setRateAmount(tag('rate_amount') ?? '');
setRateCurrency(tag('rate_currency') ?? 'sats');
setRateUnit(tag('rate_unit') ?? 'hour');
setPhone(tag('phone') ?? '');
setEmail(tag('email') ?? '');
setWebsite(tag('website') ?? '');
@ -312,6 +328,11 @@ export default function BecomeCoachPage() {
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.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 (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
@ -470,9 +491,18 @@ export default function BecomeCoachPage() {
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('labUi.coachForm.identity')}</Label>
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<p className="font-black text-lg">{getDisplayName()}</p>
<p className="text-sm text-gray-400 mt-1">{t('labUi.become.fromNostrProfile')}</p>
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-700 bg-gray-800/50">
<div className="h-12 w-12 rounded-full overflow-hidden bg-gray-700 flex items-center justify-center flex-shrink-0">
{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>
@ -510,6 +540,8 @@ export default function BecomeCoachPage() {
<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">
<Label>{t('labUi.coachForm.skillsLabel')}</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
@ -559,8 +591,10 @@ export default function BecomeCoachPage() {
idPrefix="age"
/>
<FormSection title={t('labUi.coachForm.sectionCredentials')} />
<OptionChips
label={t('labUi.coachForm.credentialsLabel')}
label=""
hint={t('labUi.coachForm.credentialsHint')}
options={COACH_CREDENTIALS}
selected={credentials}
@ -587,13 +621,13 @@ export default function BecomeCoachPage() {
{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">
<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}
<button
type="button"
onClick={() => setLanguages((prev) => prev.filter((l) => l !== 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" />
</button>
@ -603,8 +637,10 @@ export default function BecomeCoachPage() {
)}
</div>
<FormSection title={t('labUi.coachForm.sectionAvailability')} />
<OptionChips
label={t('labUi.coachForm.availabilityLabel')}
label=""
options={COACH_AVAILABILITY}
selected={availability}
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>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<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" />
<div className="space-y-2">
<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" />
</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 className="space-y-2">
<Label htmlFor="rate">{t('labUi.coachForm.hourlyRate')}</Label>
<Input id="rate" placeholder={t('labUi.coachForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
<div className="space-y-1.5">
<Label htmlFor="rate" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.rateNotesLabel')}</Label>
<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>
<FormSection title={t('labUi.coachForm.sectionWhereYouCoach')} />
<div className="space-y-2">
<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" />
@ -694,6 +764,8 @@ export default function BecomeCoachPage() {
</Button>
</div>
<FormSection title={t('labUi.coachForm.sectionPortfolio')} />
<div className="space-y-2">
<Label>{t('labUi.become.profilePhoto')}</Label>
{image ? (

View file

@ -41,7 +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 { 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 { useTranslation } from '@/contexts/language';
import { OfficialBadge } from '@/components/OfficialBadge';
@ -199,6 +199,14 @@ export default function CoachDetailPage() {
};
const galleryImages = parr('photos').map((url) => ({ url }));
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> = {
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>
</div>
)}
{profile.rate && (
{rateDisplay && (
<div>
<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>

View file

@ -685,7 +685,7 @@ function LabToolbar({
key={key}
onClick={() => onRemoveSkill(key)}
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
)}
>
@ -700,7 +700,7 @@ function LabToolbar({
key={key}
onClick={() => onRemoveCoachFacet(facet, value)}
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
)}
>
@ -989,7 +989,7 @@ function FilterDrawer({
key={value}
onClick={() => onToggleCoachFacet(key, value)}
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
? accent.chip
: `bg-white/5 border-white/10 text-gray-400 ${accent.chipHover}`