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
|
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
6
NIP.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue