From 88a3e4d1b4f10f7e89b37d7ab2313680430f102c Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 4 Oct 2026 10:49:48 +0700 Subject: [PATCH] Generalize the coach profile enrichment to every Lab craft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coach portfolio/experience/payments treatment is now a shared layer instead of 12 copies: - src/lib/labExtras.ts: parseLabExtras + shared option sets (languages, availability, structured rate, payment methods, booking fee, gallery/reel). - components/LabExtrasFields.tsx: the self-contained form block — prefills from the existing event, owns its state, and reports the managed tags via onChange. - components/LabExtrasSection.tsx: the detail block (all crafts). Wired into CraftProfileForm (the 8 data-driven crafts) and the four bespoke pages (filmer/editor/photographer/builder); CoachDetailPage renders the shared section for every craft. Per-craft buying units (rateUnits) are set per craft in becomeRoles.tsx (e.g. barber 'per cut', musician 'per gig', photographer 'per shoot'). /lab facet filters now read parseLabExtras, so availability/languages filter across crafts. Coach-specific formats/levels/ages/credentials remain coach-only. Money stays custody-free: declared methods + a booking fee paid directly to the provider. --- AGENTS.md | 16 ++ NIP.md | 28 ++ src/components/CraftProfileForm.tsx | 18 ++ src/components/LabExtrasFields.tsx | 387 +++++++++++++++++++++++++++ src/components/LabExtrasSection.tsx | 205 ++++++++++++++ src/i18n/en.ts | 9 + src/i18n/es.ts | 9 + src/lib/becomeRoles.tsx | 9 + src/lib/labExtras.ts | 140 ++++++++++ src/pages/BecomeBuilderPage.tsx | 13 + src/pages/BecomeEditorPage.tsx | 13 + src/pages/BecomeFilmerPage.tsx | 13 + src/pages/BecomePhotographerPage.tsx | 13 + src/pages/CoachDetailPage.tsx | 191 +------------ src/pages/LabPage.tsx | 24 +- 15 files changed, 898 insertions(+), 190 deletions(-) create mode 100644 src/components/LabExtrasFields.tsx create mode 100644 src/components/LabExtrasSection.tsx create mode 100644 src/lib/labExtras.ts diff --git a/AGENTS.md b/AGENTS.md index a461c81..4905586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,22 @@ session with **no other memory of this project**. So: address. Fiat/card and a refundable deposit are deliberately **out of scope** (no custody/KYC; escrow stays native-only/unverified). No tag named `payment` — that's the marketplace offer pointer. + - **Shared Lab enrichment (2026-10-04 — all crafts):** the coach fields above + were generalized so every Lab kind (35001–35014) gets the same + craft-agnostic treatment without copy-paste. **`src/lib/labExtras.ts`** + (`parseLabExtras` + option sets), **`components/LabExtrasFields.tsx`** (the + form block) and **`components/LabExtrasSection.tsx`** (the detail block). + Covered: languages, availability + booking link, structured rate, + payment methods/note/PWYC/booking fee, portfolio gallery + reel. + `CraftProfileForm` (the 8 data-driven crafts) and the 4 bespoke pages + (filmer/editor/photographer/builder) render `LabExtrasFields` and append its + managed tags on save; the shared `CoachDetailPage` renders + `LabExtrasSection` for every craft (coach-specific formats/levels/ages/ + credentials stay in the coach block). Per-craft *context* stays in + `skillSections` + `extraFields`, and per-craft `rateUnits` (billing units) are + set in `becomeRoles.tsx`. `/lab` filters read `parseLabExtras` for all crafts. + ⚠️ A new craft-specific freeform `extraField` is **invisible** unless the + detail page has a matching `pget('…')` section — add both together. - **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 diff --git a/NIP.md b/NIP.md index 403bdd7..2d5c5bd 100644 --- a/NIP.md +++ b/NIP.md @@ -1884,6 +1884,34 @@ They share a common base schema and are distinguished by kind and a self-tag. below), `p` (collective member pubkeys — see Collective Membership below), `a` (spot references to kind 30818), `alt`. +#### Shared enrichment (all Lab crafts) + +Every Lab provider kind (35001–35014) may also carry the same craft-agnostic +enrichment tags — how they work, how to pay them, and their media. They are +repeatable, **not relay-indexed** (clients filter them client-side), and parsed +by **`src/lib/labExtras.ts`** (`parseLabExtras`), which the shared profile detail +block (`LabExtrasSection`) renders for every craft: + +| Tag | Values | Meaning | +|-----|--------|---------| +| `language` | free-form | Languages the provider works in | +| `availability` | `weekdays`, `weekends`, `evenings`, `after-school`, `school-holidays`, `by-appointment`, `seasonal` | When they're available | +| `booking` | URL | Optional booking / scheduling link | +| `rate_amount` + `rate_currency` + `rate_unit` | number · `sats`/`USD`/`EUR`/`GBP` · `hour`/`session`/`half-day`/`day`/`project`/`event`/`gig`/`shoot`/`piece`/`cut`/`month` | Structured rate (legacy freeform `rate` still renders and doubles as a note) | +| `payment_method` | `cash`, `lightning`, `cashu`, `bank`, `other` | Accepted methods — a **declaration**, not an integration | +| `payment_note` | free-form | Note about payment | +| `booking_fee` + `booking_fee_currency` | number · currency | Optional fee to confirm a booking (paid directly to the provider) | +| `pay_what_you_can` | `"true"` | Sliding-scale pricing | +| `photo` | URL | Repeatable portfolio gallery (max 12) | +| `video` | URL | Highlights reel (YouTube / Instagram) | + +> **Money note:** the app never holds funds, never collects fiat/KYC, and never +> signs a payment event. A `booking_fee` is paid **directly** to the provider's +> kind-0 `lud16` and is **not escrowed/refundable through SkateSpots**. Fiat/card +> rails and a refundable deposit (which would need the native-only Cashu escrow) +> are deliberately out of scope. No tag named `payment` — that's the marketplace +> offer pointer. + ### Pro / Featured Listings (`featured` tag) Lab profiles, calendar events (kind 31923), and skate tours (kind 30823) diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index ca1298b..c8d210d 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -16,12 +16,14 @@ import { useToast } from '@/hooks/useToast'; import { LoginArea } from '@/components/auth/LoginArea'; import { NofferField } from '@/components/NofferField'; import { PaymentReadinessNote } from '@/components/PaymentReadinessNote'; +import { LabExtrasFields } from '@/components/LabExtrasFields'; import { EntityTypeField } from '@/components/EntityTypeField'; import { CrewLinkField } from '@/components/CrewLinkField'; import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; import { isValidNoffer, type EntityType } from '@/lib/types'; +import type { LabOption } from '@/lib/labExtras'; import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl'; import { useTranslation } from '@/contexts/language'; import { cn } from '@/lib/utils'; @@ -82,6 +84,8 @@ export interface CraftFormConfig { rateLabel?: string; /** Label for the portfolio field. */ portfolioLabel?: string; + /** Billing units relevant to this craft (structured-rate dropdown). */ + rateUnits?: LabOption[]; } export function CraftProfileForm({ config }: { config: CraftFormConfig }) { @@ -90,6 +94,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { accentText, accentBg, accentChip, accentChipHover, icon: Icon, skillSections, extraFields = [], allowCollective = true, showRate = true, rateLabel = 'Rate', portfolioLabel = 'Portfolio Link', + rateUnits, } = config; const navigate = useNavigate(); @@ -125,6 +130,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); const [existingGeohash, setExistingGeohash] = useState(undefined); + const [extraTags, setExtraTags] = useState([]); // Load + prefill an existing profile so this is an EDIT, not a blind overwrite. const { data: existingProfile } = useMyLabProfileEvent(kind, identifier); @@ -283,6 +289,8 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { } tags.push(['entity', entityType]); + // Shared enrichment (languages, availability, rate, payments, portfolio). + for (const extra of extraTags) tags.push(extra); // Preserve foreign `a` tags (spot references) — this form doesn't manage // them, and an addressable re-publish replaces the entire tag set. for (const tag of existingProfile?.tags ?? []) { @@ -552,6 +560,16 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) { + {/* Shared enrichment: languages, availability, rate, payments, portfolio */} + + {/* Entity type + crew link (collective-capable crafts) */} {allowCollective && ( diff --git a/src/components/LabExtrasFields.tsx b/src/components/LabExtrasFields.tsx new file mode 100644 index 0000000..f14cd0c --- /dev/null +++ b/src/components/LabExtrasFields.tsx @@ -0,0 +1,387 @@ +import { useEffect, useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { useToast } from '@/hooks/useToast'; +import { PhotoDropzone } from '@/components/PhotoDropzone'; +import { ImageWithFallback } from '@/components/ImageWithFallback'; +import { useTranslation } from '@/contexts/language'; +import { normalizeUrlInput } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; +import { Check, X } from 'lucide-react'; +import { + ALL_RATE_UNITS, + AVAILABILITY_OPTIONS, + DEFAULT_RATE_UNITS, + PAYMENT_METHODS, + RATE_CURRENCIES, + type LabOption, +} from '@/lib/labExtras'; + +interface LabExtrasFieldsProps { + /** Raw tags of the existing profile, for prefill on edit. */ + existingTags?: string[][]; + /** + * Called with the COMPLETE set of tags this component manages (after every + * change, including the initial prefill), so the host form can append them on + * publish without owning any of this state. + */ + onChange: (tags: string[][]) => void; + accentText: string; + accentChip: string; + accentChipHover: string; + /** Billing units relevant to this craft (defaults to a generic set). */ + rateUnits?: LabOption[]; + /** Hide the portfolio gallery (default false). */ + hideGallery?: boolean; + /** Hide the highlight-reel video field (default false). */ + hideVideo?: boolean; +} + +/** Tag values this component always owns, so prefill can clear stale ones. */ + +function Chips({ + options, + selected, + onToggle, + accentChip, + accentChipHover, + t, +}: { + options: LabOption[]; + selected: string[]; + onToggle: (value: string) => void; + accentChip: string; + accentChipHover: string; + t: (key: string) => string; +}) { + return ( +
+ {options.map((o) => { + const active = selected.includes(o.value); + return ( + + ); + })} +
+ ); +} + +/** + * The shared, craft-agnostic enrichment block for every Lab provider form: + * languages, availability, structured rate, payments (+ PWYC + booking fee), + * portfolio gallery, highlight reel and a booking link. + * + * Self-contained: it prefills from `existingTags`, owns its state, and reports + * the full managed tag set via `onChange` so host forms stay tiny. + */ +export function LabExtrasFields({ + existingTags, + onChange, + accentText, + accentChip, + accentChipHover, + rateUnits = DEFAULT_RATE_UNITS, + hideGallery = false, + hideVideo = false, +}: LabExtrasFieldsProps) { + const { t } = useTranslation(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const { toast } = useToast(); + + const [languages, setLanguages] = useState([]); + const [languageInput, setLanguageInput] = useState(''); + const [availability, setAvailability] = useState([]); + const [rateAmount, setRateAmount] = useState(''); + const [rateCurrency, setRateCurrency] = useState('sats'); + const [rateUnit, setRateUnit] = useState(rateUnits[0]?.value ?? 'hour'); + const [paymentMethods, setPaymentMethods] = useState([]); + const [paymentNote, setPaymentNote] = useState(''); + const [bookingFee, setBookingFee] = useState(''); + const [bookingFeeCurrency, setBookingFeeCurrency] = useState('sats'); + const [payWhatYouCan, setPayWhatYouCan] = useState(false); + const [photos, setPhotos] = useState([]); + const [video, setVideo] = useState(''); + const [booking, setBooking] = useState(''); + const [prefilled, setPrefilled] = useState(false); + + // Prefill once, when the (async) existing profile arrives. + useEffect(() => { + if (prefilled || !existingTags) return; + setPrefilled(true); + const tag = (n: string) => existingTags.find(([t]) => t === n)?.[1]; + const vals = (n: string, lower = true) => existingTags.filter(([t]) => t === n).map(([, v]) => (lower ? v.toLowerCase() : v)); + setLanguages(vals('language', false)); + setAvailability(vals('availability')); + setRateAmount(tag('rate_amount') ?? ''); + setRateCurrency(tag('rate_currency') ?? 'sats'); + setRateUnit(tag('rate_unit') ?? rateUnits[0]?.value ?? 'hour'); + setPaymentMethods(vals('payment_method')); + setPaymentNote(tag('payment_note') ?? ''); + setBookingFee(tag('booking_fee') ?? ''); + setBookingFeeCurrency(tag('booking_fee_currency') ?? 'sats'); + setPayWhatYouCan(existingTags.some(([t, v]) => t === 'pay_what_you_can' && v === 'true')); + setPhotos(vals('photo', false)); + setVideo(tag('video') ?? ''); + setBooking(tag('booking') ?? ''); + }, [existingTags, prefilled, rateUnits]); + + // Report the full managed tag set whenever anything changes. + useEffect(() => { + const tags: string[][] = []; + for (const v of languages) tags.push(['language', v]); + for (const v of availability) tags.push(['availability', v]); + if (rateAmount.trim()) { + tags.push(['rate_amount', rateAmount.trim()]); + tags.push(['rate_currency', rateCurrency]); + tags.push(['rate_unit', rateUnit]); + } + for (const m of paymentMethods) tags.push(['payment_method', m]); + if (paymentNote.trim()) tags.push(['payment_note', paymentNote.trim()]); + if (bookingFee.trim()) { + tags.push(['booking_fee', bookingFee.trim()]); + tags.push(['booking_fee_currency', bookingFeeCurrency]); + } + if (payWhatYouCan) tags.push(['pay_what_you_can', 'true']); + for (const p of photos) tags.push(['photo', p]); + if (video.trim()) tags.push(['video', video.trim()]); + if (booking.trim()) tags.push(['booking', normalizeUrlInput(booking)]); + onChange(tags); + }, [languages, availability, rateAmount, rateCurrency, rateUnit, paymentMethods, paymentNote, bookingFee, bookingFeeCurrency, payWhatYouCan, photos, video, booking, onChange]); + + const toggle = (list: string[], setList: (v: string[]) => void, value: string) => + setList(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]); + + const addLanguage = () => { + const v = languageInput.trim(); + if (!v) return; + setLanguages((prev) => (prev.some((l) => l.toLowerCase() === v.toLowerCase()) ? prev : [...prev, v])); + setLanguageInput(''); + }; + + const handleGalleryUpload = async (files: File[]) => { + const room = 12 - photos.length; + if (room <= 0) return; + try { + const uploaded: string[] = []; + for (const file of files.slice(0, room)) { + const tags = await uploadFile(file); + const url = tags[0]?.[1]; + if (url) uploaded.push(url); + } + if (uploaded.length > 0) { + setPhotos((prev) => [...prev, ...uploaded].slice(0, 12)); + toast({ title: t('labUi.coachForm.galleryUploaded', { count: uploaded.length }) }); + } + } catch (error) { + toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' }); + } + }; + + return ( +
+ {/* Languages */} +
+ +

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

+
+ setLanguageInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addLanguage(); } }} + placeholder={t('labUi.coachForm.languagePlaceholder')} + className="bg-white/5 border-white/10" + /> + +
+ {languages.length > 0 && ( +
+ {languages.map((lang) => ( + + {lang} + + + ))} +
+ )} +
+ + {/* Availability + booking link */} +
+
+ +
+ toggle(availability, setAvailability, v)} accentChip={accentChip} accentChipHover={accentChipHover} t={t} /> +
+
+
+ + setBooking(e.target.value)} + placeholder="https://cal.com/yourname" + className="mt-1.5 bg-white/5 border-white/10" + /> +

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

+
+
+ + {/* Structured rate */} +
+ +
+ setRateAmount(e.target.value.replace(/[^0-9.]/g, ''))} + className="bg-white/5 border-white/10" + /> + + +
+

{t('labUi.craftForm.rateLeftBlankNote')}

+
+ + {/* Payments */} +
+
+ +

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

+
+ toggle(paymentMethods, setPaymentMethods, v)} accentChip={accentChip} accentChipHover={accentChipHover} t={t} /> +
+
+ + + +
+ +
+ setBookingFee(e.target.value.replace(/[^0-9.]/g, ''))} + className="bg-white/5 border-white/10" + /> + +
+

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

+
+ +
+ + setPaymentNote(e.target.value)} + placeholder={t('labUi.coachForm.paymentNotePlaceholder')} + className="mt-1.5 bg-white/5 border-white/10" + /> +
+
+ + {/* Portfolio gallery + reel */} + {(!hideGallery || !hideVideo) && ( +
+ + {!hideGallery && ( +
+

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

+ {photos.length > 0 && ( +
+ {photos.map((url) => ( +
+ + +
+ ))} +
+ )} + {photos.length < 12 && ( +
+ +
+ )} +
+ )} + {!hideVideo && ( +
+ + setVideo(e.target.value)} + placeholder="https://youtube.com/watch?v=…" + className="mt-1.5 bg-white/5 border-white/10" + /> +

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

+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/LabExtrasSection.tsx b/src/components/LabExtrasSection.tsx new file mode 100644 index 0000000..8d41045 --- /dev/null +++ b/src/components/LabExtrasSection.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { + Languages as LanguagesIcon, + CalendarClock, + Images, + Clapperboard, + ExternalLink, + Banknote, + Zap, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ImageLightbox } from '@/components/ImageLightbox'; +import { ImageWithFallback } from '@/components/ImageWithFallback'; +import { PayWithSats } from '@/components/PayWithSats'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useTranslation } from '@/contexts/language'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { parseCrewVideo } from '@/lib/videoEmbed'; +import { + labOptionLabel, + PAYMENT_METHODS, + AVAILABILITY_OPTIONS, + RATE_CURRENCIES, + type LabExtras, +} from '@/lib/labExtras'; + +interface LabAccent { + text: string; + bg: string; + border: string; +} + +/** + * Craft-agnostic detail block rendered on every Lab provider page: declared + * payment methods, a direct booking fee, languages, availability + booking + * link, and the portfolio gallery / highlight reel. + * + * Reads straight off the provider event via `parseLabExtras`, so it works for + * coach/filmer/photographer/… identically. Owns its own lightbox + pay sheet. + */ +export function LabExtrasSection({ extras, accent, profilePubkey }: { extras?: LabExtras; accent: LabAccent; profilePubkey: string }) { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const author = useAuthor(profilePubkey); + const [lightboxIndex, setLightboxIndex] = useState(null); + const [payOpen, setPayOpen] = useState(false); + + if (!extras) return null; + + const galleryImages = extras.photos.map((url) => ({ url })); + const videoEmbed = parseCrewVideo(extras.video); + const bookingFee = extras.bookingFee; + const bookingFeeCurrency = extras.bookingFeeCurrency ?? 'sats'; + const bookingFeeDisplay = bookingFee + ? `${bookingFee} ${labOptionLabel(RATE_CURRENCIES, bookingFeeCurrency, t)}`.trim() + : undefined; + const bookingFeeSats = bookingFee && bookingFeeCurrency === 'sats' ? parseInt(bookingFee, 10) : NaN; + const canPayBookingFee = Number.isFinite(bookingFeeSats) && bookingFeeSats > 0 && !!author.data?.metadata?.lud16?.trim(); + const isOwnerView = user?.pubkey === profilePubkey; + + const hasLanguages = extras.languages.length > 0; + const hasAvailability = extras.availability.length > 0 || !!sanitizeUrl(extras.booking); + const hasPayments = extras.paymentMethods.length > 0 || extras.payWhatYouCan || !!bookingFee || !!extras.paymentNote; + const hasPortfolio = galleryImages.length > 0 || !!videoEmbed; + + if (!hasLanguages && !hasAvailability && !hasPayments && !hasPortfolio) return null; + + return ( +
+ {hasLanguages && ( +
+

+ {t('labUi.detail.languages')} +

+
+ {extras.languages.map((l) => ( + {l} + ))} +
+
+ )} + + {hasAvailability && ( +
+

+ {t('labUi.detail.availability')} +

+ {extras.availability.length > 0 && ( +
+ {extras.availability.map((v) => ( + + {labOptionLabel(AVAILABILITY_OPTIONS, v, t)} + + ))} +
+ )} + {sanitizeUrl(extras.booking) && ( + + + {t('labUi.detail.bookSession')} + + + )} +
+ )} + + {hasPayments && ( +
+

+ {t('labUi.detail.paymentsTitle')} +

+ {(extras.paymentMethods.length > 0 || extras.payWhatYouCan) && ( +
+ {extras.paymentMethods.map((m) => ( + + {labOptionLabel(PAYMENT_METHODS, m, t)} + + ))} + {extras.payWhatYouCan && ( + + {t('labUi.detail.payWhatYouCan')} + + )} +
+ )} + {extras.paymentNote &&

{extras.paymentNote}

} + {bookingFee && ( +
+
+
+

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

+

{bookingFeeDisplay}

+
+ {canPayBookingFee && !isOwnerView && ( + + )} +
+

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

+ {!canPayBookingFee && ( +

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

+ )} +
+ )} + {payOpen && canPayBookingFee && ( + + )} +
+ )} + + {hasPortfolio && ( +
+

+ {t('labUi.detail.portfolio')} +

+ {galleryImages.length > 0 && ( +
+ {galleryImages.map((img, i) => ( + + ))} +
+ )} + {videoEmbed && ( + + + {t('labUi.detail.watchReel', { platform: videoEmbed.source === 'youtube' ? 'YouTube' : 'Instagram' })} + + + )} + setLightboxIndex(null)} + /> +
+ )} +
+ ); +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index fe33a8c..6f2d840 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -2235,6 +2235,14 @@ labUi: { perHalfDay: 'per half-day', perDay: 'per day', perCamp: 'per camp', + perProject: 'per project', + perEvent: 'per event', + perGig: 'per gig', + perShoot: 'per shoot', + perPiece: 'per piece', + perCut: 'per cut', + perMonth: 'per month', + availByAppointment: 'By appointment', rateNotesLabel: 'Rate notes (optional)', rateNotesPlaceholder: 'Sliding scale, first session free, travel included…', sectionPayments: 'Payments', @@ -2726,6 +2734,7 @@ labUi: { gettingPaid: 'Getting Paid', updateProfile: 'Update Profile', publishProfile: 'Publish Profile', + rateLeftBlankNote: 'Optional — leave this blank if the rate above is enough.', sectionRequired: '{{section}} required', sectionRequiredDesc: 'Select at least one option under "{{section}}".', updated: 'Profile updated!', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 52a1b08..63b89a7 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -2235,6 +2235,14 @@ labUi: { perHalfDay: 'por media jornada', perDay: 'por día', perCamp: 'por campus', + perProject: 'por proyecto', + perEvent: 'por evento', + perGig: 'por bolo', + perShoot: 'por sesión', + perPiece: 'por pieza', + perCut: 'por corte', + perMonth: 'por mes', + availByAppointment: 'Con cita', rateNotesLabel: 'Notas sobre la tarifa (opcional)', rateNotesPlaceholder: 'Tarifa social, primera clase gratis, desplazamiento incluido…', sectionPayments: 'Pagos', @@ -2726,6 +2734,7 @@ labUi: { gettingPaid: 'Cobrar', updateProfile: 'Actualizar perfil', publishProfile: 'Publicar perfil', + rateLeftBlankNote: 'Opcional — déjalo en blanco si la tarifa de arriba es suficiente.', sectionRequired: '{{section}} obligatorio', sectionRequiredDesc: 'Selecciona al menos una opción en "{{section}}".', updated: '¡Perfil actualizado!', diff --git a/src/lib/becomeRoles.tsx b/src/lib/becomeRoles.tsx index 3742c5f..bfd9a77 100644 --- a/src/lib/becomeRoles.tsx +++ b/src/lib/becomeRoles.tsx @@ -2,6 +2,7 @@ import { lazy, type ComponentType, type LazyExoticComponent } from 'react'; import { Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Sparkles, } from 'lucide-react'; +import { pickRateUnits } from '@/lib/labExtras'; import type { CraftFormConfig } from '@/components/CraftProfileForm'; import { SKATE_ARTIST_KIND, ARTIST_SERVICE_LABELS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, @@ -81,6 +82,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-fuchsia-600 text-white border-fuchsia-600', accentChipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400', icon: Palette, + rateUnits: pickRateUnits('piece', 'project', 'hour', 'day'), rateLabel: 'Rate', portfolioLabel: 'Portfolio / Shop link', skillSections: [ @@ -106,6 +108,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-lime-500 text-black border-lime-500', accentChipHover: 'hover:border-lime-500/60 hover:text-lime-400', icon: SprayCan, + rateUnits: pickRateUnits('piece', 'project', 'day', 'hour'), rateLabel: 'Rate', portfolioLabel: 'Portfolio link (IG, site)', skillSections: [ @@ -134,6 +137,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-sky-500 text-white border-sky-500', accentChipHover: 'hover:border-sky-500/60 hover:text-sky-400', icon: Music, + rateUnits: pickRateUnits('gig', 'event', 'hour', 'day'), rateLabel: 'Booking Rate', portfolioLabel: 'Website / EPK link', skillSections: [ @@ -165,6 +169,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-yellow-500 text-black border-yellow-500', accentChipHover: 'hover:border-yellow-500/60 hover:text-yellow-400', icon: Gavel, + rateUnits: pickRateUnits('event', 'day', 'hour'), rateLabel: 'Day Rate / Fee', portfolioLabel: 'Results archive / profile link', skillSections: [ @@ -193,6 +198,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-teal-600 text-white border-teal-600', accentChipHover: 'hover:border-teal-500/60 hover:text-teal-400', icon: Tent, + rateUnits: pickRateUnits('event', 'day', 'project'), rateLabel: 'Event Fee / Budget', portfolioLabel: 'Past events / results link', skillSections: [ @@ -221,6 +227,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-rose-600 text-white border-rose-600', accentChipHover: 'hover:border-rose-500/60 hover:text-rose-400', icon: Brush, + rateUnits: pickRateUnits('hour', 'piece', 'session'), rateLabel: 'Rate / Hourly', portfolioLabel: 'Portfolio link (IG, site)', skillSections: [ @@ -249,6 +256,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-emerald-600 text-white border-emerald-600', accentChipHover: 'hover:border-emerald-500/60 hover:text-emerald-400', icon: Dumbbell, + rateUnits: pickRateUnits('session', 'month', 'hour'), rateLabel: 'Rate / Session', portfolioLabel: 'Profile / booking link', skillSections: [ @@ -278,6 +286,7 @@ export const BECOME_ROLES: Record = { accentChip: 'bg-cyan-600 text-white border-cyan-600', accentChipHover: 'hover:border-cyan-500/60 hover:text-cyan-400', icon: Sparkles, + rateUnits: pickRateUnits('cut', 'hour', 'session'), rateLabel: 'Price / Cut', portfolioLabel: 'Cuts gallery / IG link', skillSections: [ diff --git a/src/lib/labExtras.ts b/src/lib/labExtras.ts new file mode 100644 index 0000000..489b781 --- /dev/null +++ b/src/lib/labExtras.ts @@ -0,0 +1,140 @@ +/** + * Shared "extras" for every Lab provider profile (all craft kinds). + * + * The coach profile pioneered these fields; rather than copy them 12 times, + * this module defines ONE parser + option sets that every craft's form and the + * shared detail page use. The tags are identical across crafts: + * + * language / availability / rate_amount / rate_currency / rate_unit / + * payment_method / payment_note / booking_fee / booking_fee_currency / + * pay_what_you_can / photo / video / booking + * + * Craft-specific *context* stays in each form's `skillSections` + `extraFields`; + * this is the craft-agnostic enrichment (how to reach them, how to pay, media). + */ +import type { NostrEvent } from '@nostrify/nostrify'; +import { COACH_PAYMENT_METHODS } from '@/lib/coachOptions'; + +/** A stored value + its i18n key. */ +export interface LabOption { + value: string; + key: string; +} + +/** Currencies a provider can price in (sats first — it's a Bitcoin app). */ +export const RATE_CURRENCIES: LabOption[] = [ + { value: 'sats', key: 'labUi.coachForm.rateCurrencySats' }, + { value: 'USD', key: 'labUi.coachForm.rateCurrencyUsd' }, + { value: 'EUR', key: 'labUi.coachForm.rateCurrencyEur' }, + { value: 'GBP', key: 'labUi.coachForm.rateCurrencyGbp' }, +]; + +/** Every billing unit any craft might use (forms pick a subset by context). */ +export const ALL_RATE_UNITS: LabOption[] = [ + { 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: 'project', key: 'labUi.coachForm.perProject' }, + { value: 'event', key: 'labUi.coachForm.perEvent' }, + { value: 'gig', key: 'labUi.coachForm.perGig' }, + { value: 'shoot', key: 'labUi.coachForm.perShoot' }, + { value: 'piece', key: 'labUi.coachForm.perPiece' }, + { value: 'cut', key: 'labUi.coachForm.perCut' }, + { value: 'month', key: 'labUi.coachForm.perMonth' }, +]; + +/** Sensible default units when a craft doesn't specify its own. */ +export const DEFAULT_RATE_UNITS: LabOption[] = ALL_RATE_UNITS.filter((u) => + ['hour', 'session', 'half-day', 'day', 'project'].includes(u.value), +); + +/** Pick a craft-specific subset of billing units, in the canonical order. */ +export function pickRateUnits(...values: string[]): LabOption[] { + return ALL_RATE_UNITS.filter((u) => values.includes(u.value)); +} + +/** Generic availability windows — union of coach + craft conventions. */ +export const AVAILABILITY_OPTIONS: LabOption[] = [ + { value: 'weekdays', key: 'labUi.coachForm.availWeekdays' }, + { value: 'weekends', key: 'labUi.coachForm.availWeekends' }, + { value: 'evenings', key: 'labUi.coachForm.availEvenings' }, + { value: 'after-school', key: 'labUi.coachForm.availAfterSchool' }, + { value: 'school-holidays', key: 'labUi.coachForm.availHolidays' }, + { value: 'by-appointment', key: 'labUi.coachForm.availByAppointment' }, + { value: 'seasonal', key: 'labUi.coachForm.availSeasonal' }, +]; + +/** Accepted payment methods (shared with the coach profile). */ +export const PAYMENT_METHODS = COACH_PAYMENT_METHODS; + +/** The craft-agnostic enrichment parsed off any Lab provider event. */ +export interface LabExtras { + languages: string[]; + availability: string[]; + rateAmount?: string; + rateCurrency?: string; + rateUnit?: string; + paymentMethods: string[]; + paymentNote?: string; + bookingFee?: string; + bookingFeeCurrency?: string; + payWhatYouCan: boolean; + photos: string[]; + video?: string; + booking?: string; + // Coach-origin facets (kept here so the Lab filter reads one source for all + // crafts; other crafts simply leave them empty). + formats: string[]; + levels: string[]; + ageGroups: string[]; + credentials: string[]; + certNote?: string; +} + +/** Read the shared extras off any Lab provider event (craft-agnostic). */ +export function parseLabExtras(event: NostrEvent): LabExtras { + const tag = (n: string) => event.tags.find(([t]) => t === n)?.[1]; + const vals = (n: string, lower = true): string[] => [ + ...new Set( + event.tags + .filter(([t, v]) => t === n && !!v?.trim()) + .map(([, v]) => (lower ? v.trim().toLowerCase() : v.trim())), + ), + ]; + + return { + languages: vals('language', false), + availability: vals('availability'), + rateAmount: tag('rate_amount'), + rateCurrency: tag('rate_currency'), + rateUnit: tag('rate_unit'), + paymentMethods: vals('payment_method'), + paymentNote: tag('payment_note'), + bookingFee: tag('booking_fee'), + bookingFeeCurrency: tag('booking_fee_currency'), + payWhatYouCan: event.tags.some(([t, v]) => t === 'pay_what_you_can' && v === 'true'), + photos: event.tags + .filter(([t, v]) => t === 'photo' && !!v?.trim()) + .map(([, v]) => v.trim()) + .filter((v, i, arr) => arr.indexOf(v) === i) + .slice(0, 12), + video: tag('video'), + booking: tag('booking'), + formats: vals('format'), + levels: vals('level'), + ageGroups: vals('age'), + credentials: vals('cert'), + certNote: tag('cert_note'), + }; +} + +/** Resolve a stored option value to its translated label (raw value fallback). */ +export function labOptionLabel( + options: LabOption[], + value: string, + t: (key: string) => string, +): string { + const key = options.find((o) => o.value === value)?.key; + return key ? t(key) : value; +} diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index 14ffa45..3f1bfcc 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -46,6 +46,8 @@ import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, H import { LabProUpgrade } from '@/components/LabProUpgrade'; import { usePlatformFee } from '@/hooks/usePlatformFee'; import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile'; +import { LabExtrasFields } from '@/components/LabExtrasFields'; +import { pickRateUnits } from '@/lib/labExtras'; const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS); const SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS); @@ -129,6 +131,7 @@ export default function BecomeBuilderPage() { const [isGettingLocation, setIsGettingLocation] = useState(false); const [titleInput, setTitleInput] = useState(''); const [prefilled, setPrefilled] = useState(false); + const [extraTags, setExtraTags] = useState([]); const [existingGeohash, setExistingGeohash] = useState(undefined); // Prefill from an existing profile so this is an edit, not a blind overwrite. @@ -292,6 +295,7 @@ export default function BecomeBuilderPage() { if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); tags.push(['entity', entityType]); + for (const extra of extraTags) tags.push(extra); if (entityType === 'collective') { for (const pk of members) tags.push(['p', pk]); if (crewRef) tags.push(['a', crewRef]); @@ -617,6 +621,15 @@ export default function BecomeBuilderPage() { {/* Optional Pro upgrade — free to list without it */} + + - ))} - - )} - {videoEmbed && ( - - - {t('labUi.detail.watchReel', { platform: videoEmbed.source === 'youtube' ? 'YouTube' : 'Instagram' })} - - - )} - setLightboxIndex(null)} - /> - - )} - {/* Payments — declared methods + optional direct booking fee */} - {isCoach && (paymentMethods.length > 0 || pbool('payWhatYouCan') || bookingFee || pget('paymentNote')) && ( -
-

- {t('labUi.detail.paymentsTitle')} -

- - {(paymentMethods.length > 0 || pbool('payWhatYouCan')) && ( -
- {paymentMethods.map((m) => { - const key = coachOptionKey(COACH_PAYMENT_METHODS, m); - return ( - - {key ? t(key) : m} - - ); - })} - {pbool('payWhatYouCan') && ( - - {t('labUi.detail.payWhatYouCan')} - - )} -
- )} - - {pget('paymentNote') && ( -

{pget('paymentNote')}

- )} - - {bookingFee && ( -
-
-
-

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

-

{bookingFeeDisplay}

-
- {canPayBookingFee && user?.pubkey !== profile.pubkey && ( - - )} -
-

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

- {!canPayBookingFee && ( -

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

- )} -
- )} - - {payOpen && canPayBookingFee && ( - - )} -
- )} + {/* Shared extras (all crafts): payments, languages, availability, portfolio */} + {/* In the Bag — photographer / filmer gear (freeform) */} {(isPhotographer || isFilmer || isBuilder) && pget('gear') && ( diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 6a4562e..092d244 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { filterByTrustLevel, type TrustFilterLevel } from '@/lib/trustFilter'; import { COACH_FACETS, coachOptionKey, emptyCoachFilters, type CoachFacetKey } from '@/lib/coachOptions'; +import { parseLabExtras, type LabExtras } from '@/lib/labExtras'; import { type SortOption } from '@/components/SortDropdown'; import { useLabProfiles, type LabProfile } from '@/hooks/useLabProfiles'; import { useAuthor } from '@/hooks/useAuthor'; @@ -974,7 +975,7 @@ function FilterDrawer({ {/* Coaching refinements — only for coaches, and only facets present in the loaded data (so an empty filter never appears). */} - {serviceType === 'coaching' && COACH_FACETS.map(({ key, titleKey, options }) => { + {COACH_FACETS.map(({ key, titleKey, options }) => { const values = coachFacets[key]; if (!values || values.length === 0) return null; return ( @@ -1409,9 +1410,9 @@ export default function LabPage() { setSelectedSkills((prev) => prev.filter((s) => s !== skill)); }; - // Read a repeatable array field off the union profile (empty when absent). - const profileField = useCallback((p: LabProfile, key: string): string[] => { - const v = (p as unknown as Record)[key]; + // Shared enrichment values for a profile (craft-agnostic, read off the event). + const extraValues = useCallback((p: LabProfile, key: keyof LabExtras): string[] => { + const v = parseLabExtras(p.event)[key]; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; }, []); @@ -1424,12 +1425,11 @@ export default function LabPage() { })); }; - // Only offer coaching facets that actually exist in the loaded profiles. + // Only offer facets that actually exist in the loaded profiles (any craft). const coachFacets = useMemo(() => { - const coaching = (profiles ?? []).filter((p) => p.profileType === 'coach'); - const collect = (key: string) => { + const collect = (key: CoachFacetKey) => { const set = new Set(); - for (const p of coaching) for (const v of profileField(p, key)) set.add(v); + for (const p of profiles ?? []) for (const v of extraValues(p, key)) set.add(v); return [...set].sort(); }; return { @@ -1440,7 +1440,7 @@ export default function LabPage() { availability: collect('availability'), languages: collect('languages'), } as Record; - }, [profiles, profileField]); + }, [profiles, extraValues]); const coachChips = useMemo(() => { const chips: { key: string; label: string; facet: CoachFacetKey; value: string }[] = []; @@ -1506,10 +1506,10 @@ export default function LabPage() { if (!profile.skills.some((s) => selectedSkills.includes(s))) return false; } - // Coaching facets — AND across facets, OR within one. + // Shared facet filters — AND across facets, OR within one. for (const { key } of COACH_FACETS) { const selected = coachFilters[key]; - if (selected.length > 0 && !profileField(profile, key).some((v) => selected.includes(v))) { + if (selected.length > 0 && !extraValues(profile, key).some((v) => selected.includes(v))) { return false; } } @@ -1530,7 +1530,7 @@ export default function LabPage() { }); return filtered; - }, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, getTrustScore, trustScores, sortBy, displayNameMap, coachFilters, profileField]); + }, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, getTrustScore, trustScores, sortBy, displayNameMap, coachFilters, extraValues]); // Active filter count (skills + coaching facets + trust + sort) — location and search are separate quick filters const activeFilterCount =