diff --git a/AGENTS.md b/AGENTS.md index ac45005..4cba25f 100644 --- a/AGENTS.md +++ b/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 diff --git a/NIP.md b/NIP.md index 8a96a72..2ab736d 100644 --- a/NIP.md +++ b/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 diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index 76ee386..6a9fded 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -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 = /(? { 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; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3d0a776..ed26f8b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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, diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 596faef..ec2a308 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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 ( +
{hint}
} +{hint}
} -{getDisplayName()}
-{t('labUi.become.fromNostrProfile')}
+{getDisplayName()}
+{t('labUi.become.fromNostrProfile')}
+{t('labUi.coachForm.bookingHint')}
{t('labUi.detail.yearsCount', { count: profile.experience })}
{profile.rate}
+{rateDisplay}
+ {rateAmount && pget('rate') && ( +{pget('rate')}
+ )}