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:
parent
468175a0fa
commit
fa0b9cfe1c
10 changed files with 275 additions and 33 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -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
6
NIP.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
Loading…
Reference in a new issue