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 ( +
+

{title}

+ {hint &&

{hint}

} +
+ ); +} + /** Reusable chip toggle row for the curated coach option sets. */ function OptionChips({ label, @@ -56,9 +66,9 @@ function OptionChips({ const { t } = useTranslation(); return (
- + {label && } {hint &&

{hint}

} -
+
{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(null); const [experience, setExperience] = useState(''); const [rate, setRate] = useState(''); + const [rateAmount, setRateAmount] = useState(''); + const [rateCurrency, setRateCurrency] = useState('sats'); + const [rateUnit, setRateUnit] = useState('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() {
-
-

{getDisplayName()}

-

{t('labUi.become.fromNostrProfile')}

+
+
+ {metadata?.picture ? ( + + ) : ( + + )} +
+
+

{getDisplayName()}

+

{t('labUi.become.fromNostrProfile')}

+
@@ -510,6 +540,8 @@ export default function BecomeCoachPage() { )} + +
@@ -559,8 +591,10 @@ export default function BecomeCoachPage() { idPrefix="age" /> + + 0 && (
{languages.map((lang) => ( - + {lang} @@ -603,8 +637,10 @@ export default function BecomeCoachPage() { )}
+ + toggleValue(availability, setAvailability, v)} @@ -624,17 +660,51 @@ export default function BecomeCoachPage() {

{t('labUi.coachForm.bookingHint')}

-
-
- - setExperience(e.target.value)} className="bg-gray-800 border-gray-700" /> +
+ + setExperience(e.target.value)} className="bg-gray-800 border-gray-700" /> +
+ +
+ +
+ setRateAmount(e.target.value.replace(/[^0-9.]/g, ''))} + className="bg-gray-800 border-gray-700" + /> + +
-
- - setRate(e.target.value)} className="bg-gray-800 border-gray-700" /> +
+ + setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
+ +
setLocation(e.target.value)} className="bg-gray-800 border-gray-700" /> @@ -694,6 +764,8 @@ export default function BecomeCoachPage() {
+ +
{image ? ( diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 736ca22..379e0bf 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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 = { coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors, @@ -784,10 +792,13 @@ export default function CoachDetailPage() {

{t('labUi.detail.yearsCount', { count: profile.experience })}

)} - {profile.rate && ( + {rateDisplay && (

{t('labUi.detail.rate')}

-

{profile.rate}

+

{rateDisplay}

+ {rateAmount && pget('rate') && ( +

{pget('rate')}

+ )}
)}
diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 2f76b6a..6a4562e 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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}`