Generalize the coach profile enrichment to every Lab craft
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.
This commit is contained in:
parent
357d042a13
commit
88a3e4d1b4
15 changed files with 898 additions and 190 deletions
16
AGENTS.md
16
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
|
||||
|
|
|
|||
28
NIP.md
28
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)
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(undefined);
|
||||
const [extraTags, setExtraTags] = useState<string[][]>([]);
|
||||
|
||||
// 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 }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Shared enrichment: languages, availability, rate, payments, portfolio */}
|
||||
<LabExtrasFields
|
||||
existingTags={existingProfile?.tags}
|
||||
onChange={setExtraTags}
|
||||
accentText={accentText}
|
||||
accentChip={accentChip}
|
||||
accentChipHover={accentChipHover}
|
||||
rateUnits={rateUnits}
|
||||
/>
|
||||
|
||||
{/* Entity type + crew link (collective-capable crafts) */}
|
||||
{allowCollective && (
|
||||
<Card className="bg-black/60 border-white/10">
|
||||
|
|
|
|||
387
src/components/LabExtrasFields.tsx
Normal file
387
src/components/LabExtrasFields.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{options.map((o) => {
|
||||
const active = selected.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onToggle(o.value)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'min-h-0 min-w-0 rounded-full border px-3 py-1.5 text-xs font-bold transition-all active:scale-95',
|
||||
active ? accentChip : cn('bg-white/5 border-white/10 text-gray-400', accentChipHover),
|
||||
)}
|
||||
>
|
||||
{t(o.key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string[]>([]);
|
||||
const [languageInput, setLanguageInput] = useState('');
|
||||
const [availability, setAvailability] = useState<string[]>([]);
|
||||
const [rateAmount, setRateAmount] = useState('');
|
||||
const [rateCurrency, setRateCurrency] = useState('sats');
|
||||
const [rateUnit, setRateUnit] = useState(rateUnits[0]?.value ?? 'hour');
|
||||
const [paymentMethods, setPaymentMethods] = useState<string[]>([]);
|
||||
const [paymentNote, setPaymentNote] = useState('');
|
||||
const [bookingFee, setBookingFee] = useState('');
|
||||
const [bookingFeeCurrency, setBookingFeeCurrency] = useState('sats');
|
||||
const [payWhatYouCan, setPayWhatYouCan] = useState(false);
|
||||
const [photos, setPhotos] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Languages */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/60 p-5">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.languagesLabel')}</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">{t('labUi.coachForm.languagesHint')}</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
value={languageInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button type="button" onClick={addLanguage} className={cn('min-h-0 flex-shrink-0 rounded-lg px-3 text-sm font-bold', accentChip)}>
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
{languages.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{languages.map((lang) => (
|
||||
<span key={lang} className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/5 py-1 pl-3 pr-2 text-xs font-bold text-gray-300">
|
||||
{lang}
|
||||
<button type="button" onClick={() => setLanguages((prev) => prev.filter((l) => l !== lang))} aria-label={`${t('common.remove')} ${lang}`} className="min-h-0 min-w-0 text-gray-500 hover:text-white">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Availability + booking link */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/60 p-5 space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.availabilityLabel')}</Label>
|
||||
<div className="mt-2">
|
||||
<Chips options={AVAILABILITY_OPTIONS} selected={availability} onToggle={(v) => toggle(availability, setAvailability, v)} accentChip={accentChip} accentChipHover={accentChipHover} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lab-booking" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.bookingLabel')}</Label>
|
||||
<Input
|
||||
id="lab-booking"
|
||||
type="url"
|
||||
value={booking}
|
||||
onChange={(e) => setBooking(e.target.value)}
|
||||
placeholder="https://cal.com/yourname"
|
||||
className="mt-1.5 bg-white/5 border-white/10"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">{t('labUi.coachForm.bookingHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Structured rate */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/60 p-5">
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.rateAmountLabel')}</Label>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2000"
|
||||
value={rateAmount}
|
||||
onChange={(e) => setRateAmount(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
className="bg-white/5 border-white/10"
|
||||
/>
|
||||
<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-white/10 bg-white/5 px-2 text-sm text-white"
|
||||
>
|
||||
{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-white/10 bg-white/5 px-2 text-sm text-white"
|
||||
>
|
||||
{(rateUnits.length > 0 ? rateUnits : ALL_RATE_UNITS).map((u) => <option key={u.value} value={u.value}>{t(u.key)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">{t('labUi.craftForm.rateLeftBlankNote')}</p>
|
||||
</div>
|
||||
|
||||
{/* Payments */}
|
||||
<div className="rounded-xl border border-white/10 bg-black/60 p-5 space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.sectionPayments')}</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">{t('labUi.coachForm.paymentMethodsHint')}</p>
|
||||
<div className="mt-2">
|
||||
<Chips options={PAYMENT_METHODS} selected={paymentMethods} onToggle={(v) => toggle(paymentMethods, setPaymentMethods, v)} accentChip={accentChip} accentChipHover={accentChipHover} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPayWhatYouCan((v) => !v)}
|
||||
aria-pressed={payWhatYouCan}
|
||||
className={cn(
|
||||
'flex w-full min-h-0 items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-sm font-bold transition-colors',
|
||||
payWhatYouCan ? 'border-white/20 bg-white/10 text-white' : 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border', payWhatYouCan ? accentChip : 'border-white/20')}>
|
||||
{payWhatYouCan && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
{t('labUi.coachForm.payWhatYouCanLabel')}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.bookingFeeLabel')}</Label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2000"
|
||||
value={bookingFee}
|
||||
onChange={(e) => setBookingFee(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
className="bg-white/5 border-white/10"
|
||||
/>
|
||||
<select
|
||||
value={bookingFeeCurrency}
|
||||
onChange={(e) => setBookingFeeCurrency(e.target.value)}
|
||||
aria-label={t('labUi.coachForm.bookingFeeLabel')}
|
||||
className="h-10 min-w-0 rounded-md border border-white/10 bg-white/5 px-2 text-sm text-white"
|
||||
>
|
||||
{RATE_CURRENCIES.map((c) => <option key={c.value} value={c.value}>{t(c.key)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{t('labUi.coachForm.bookingFeeHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="lab-payment-note" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.paymentNoteLabel')}</Label>
|
||||
<Input
|
||||
id="lab-payment-note"
|
||||
value={paymentNote}
|
||||
onChange={(e) => setPaymentNote(e.target.value)}
|
||||
placeholder={t('labUi.coachForm.paymentNotePlaceholder')}
|
||||
className="mt-1.5 bg-white/5 border-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio gallery + reel */}
|
||||
{(!hideGallery || !hideVideo) && (
|
||||
<div className="rounded-xl border border-white/10 bg-black/60 p-5 space-y-4">
|
||||
<Label className={cn('text-xs font-bold uppercase tracking-widest', accentText)}>{t('labUi.coachForm.sectionPortfolio')}</Label>
|
||||
{!hideGallery && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{t('labUi.coachForm.galleryHint')}</p>
|
||||
{photos.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{photos.map((url) => (
|
||||
<div key={url} className="relative aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||
<ImageWithFallback src={url} alt="" className="h-full w-full object-cover" />
|
||||
<button type="button" onClick={() => setPhotos((prev) => prev.filter((p) => p !== url))} aria-label={t('common.remove')} className="absolute right-1 top-1 min-h-0 min-w-0 rounded-full bg-black/80 p-1 hover:bg-red-500">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{photos.length < 12 && (
|
||||
<div className="mt-2">
|
||||
<PhotoDropzone
|
||||
onFiles={handleGalleryUpload}
|
||||
uploading={isUploading}
|
||||
label={t('labUi.coachForm.galleryAdd')}
|
||||
className="aspect-video"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hideVideo && (
|
||||
<div>
|
||||
<Label htmlFor="lab-video" className="text-xs font-bold uppercase tracking-widest text-gray-400">{t('labUi.coachForm.videoLabel')}</Label>
|
||||
<Input
|
||||
id="lab-video"
|
||||
type="url"
|
||||
value={video}
|
||||
onChange={(e) => setVideo(e.target.value)}
|
||||
placeholder="https://youtube.com/watch?v=…"
|
||||
className="mt-1.5 bg-white/5 border-white/10"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">{t('labUi.coachForm.videoHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/components/LabExtrasSection.tsx
Normal file
205
src/components/LabExtrasSection.tsx
Normal file
|
|
@ -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<number | null>(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 (
|
||||
<div className="mb-6 space-y-5">
|
||||
{hasLanguages && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<LanguagesIcon className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.languages')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extras.languages.map((l) => (
|
||||
<span key={l} className={`text-xs px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAvailability && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<CalendarClock className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.availability')}
|
||||
</h3>
|
||||
{extras.availability.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extras.availability.map((v) => (
|
||||
<span key={v} className={`text-xs px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
|
||||
{labOptionLabel(AVAILABILITY_OPTIONS, v, t)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sanitizeUrl(extras.booking) && (
|
||||
<a
|
||||
href={sanitizeUrl(extras.booking)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`mt-3 inline-flex items-center gap-1.5 text-sm font-bold px-4 py-2 rounded-full ${accent.bg} text-white hover:opacity-90`}
|
||||
>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
{t('labUi.detail.bookSession')}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPayments && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||
<Banknote className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.paymentsTitle')}
|
||||
</h3>
|
||||
{(extras.paymentMethods.length > 0 || extras.payWhatYouCan) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extras.paymentMethods.map((m) => (
|
||||
<span key={m} className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
|
||||
{labOptionLabel(PAYMENT_METHODS, m, t)}
|
||||
</span>
|
||||
))}
|
||||
{extras.payWhatYouCan && (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
|
||||
{t('labUi.detail.payWhatYouCan')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{extras.paymentNote && <p className="mt-2 text-sm text-gray-400">{extras.paymentNote}</p>}
|
||||
{bookingFee && (
|
||||
<div className="mt-3 rounded-lg border border-white/10 bg-black/40 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">{t('labUi.detail.bookingFee')}</p>
|
||||
<p className="text-sm font-bold text-white">{bookingFeeDisplay}</p>
|
||||
</div>
|
||||
{canPayBookingFee && !isOwnerView && (
|
||||
<Button size="sm" onClick={() => setPayOpen(true)} className={`gap-1.5 ${accent.bg} text-white font-bold hover:opacity-90`}>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t('labUi.detail.payBookingFee')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-500">{t('labUi.detail.bookingFeeNote')}</p>
|
||||
{!canPayBookingFee && (
|
||||
<p className="mt-1 text-[11px] text-gray-500">{t('labUi.detail.bookingFeeNoRail')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{payOpen && canPayBookingFee && (
|
||||
<PayWithSats
|
||||
open={payOpen}
|
||||
onOpenChange={setPayOpen}
|
||||
sellerPubkey={profilePubkey}
|
||||
amountSats={bookingFeeSats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPortfolio && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||
<Images className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.portfolio')}
|
||||
</h3>
|
||||
{galleryImages.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{galleryImages.map((img, i) => (
|
||||
<button
|
||||
key={img.url}
|
||||
type="button"
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 group"
|
||||
>
|
||||
<ImageWithFallback src={img.url} alt="" className="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{videoEmbed && (
|
||||
<a
|
||||
href={videoEmbed.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-full ${accent.bg} text-white font-bold text-sm hover:opacity-90`}
|
||||
>
|
||||
<Clapperboard className="h-4 w-4" />
|
||||
{t('labUi.detail.watchReel', { platform: videoEmbed.source === 'youtube' ? 'YouTube' : 'Instagram' })}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<ImageLightbox
|
||||
images={galleryImages}
|
||||
index={lightboxIndex}
|
||||
onIndexChange={setLightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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!',
|
||||
|
|
|
|||
|
|
@ -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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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<string, BecomeRole> = {
|
|||
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: [
|
||||
|
|
|
|||
140
src/lib/labExtras.ts
Normal file
140
src/lib/labExtras.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string[][]>([]);
|
||||
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(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() {
|
|||
</div>
|
||||
|
||||
{/* Optional Pro upgrade — free to list without it */}
|
||||
<LabExtrasFields
|
||||
existingTags={existingProfile?.tags}
|
||||
onChange={setExtraTags}
|
||||
accentText="text-amber-400"
|
||||
accentChip="bg-amber-500 text-black border-amber-500"
|
||||
accentChipHover="hover:border-amber-500/60 hover:text-amber-400"
|
||||
rateUnits={pickRateUnits('project', 'day', 'hour')}
|
||||
/>
|
||||
|
||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-amber-400" entityCoord={user ? `${SKATE_BUILDER_KIND}:${user.pubkey}:builder-profile` : undefined} />
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink,
|
|||
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(EDITOR_SERVICE_LABELS);
|
||||
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
|
||||
|
|
@ -85,6 +87,7 @@ export default function BecomeEditorPage() {
|
|||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState('');
|
||||
const [prefilled, setPrefilled] = useState(false);
|
||||
const [extraTags, setExtraTags] = useState<string[][]>([]);
|
||||
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
|
||||
|
||||
// Prefill from an existing profile so this is an edit, not a blind overwrite.
|
||||
|
|
@ -224,6 +227,7 @@ export default function BecomeEditorPage() {
|
|||
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]);
|
||||
|
|
@ -511,6 +515,15 @@ export default function BecomeEditorPage() {
|
|||
|
||||
{/* Optional Pro upgrade — free to list without it */}
|
||||
<div className="mt-4">
|
||||
<LabExtrasFields
|
||||
existingTags={existingProfile?.tags}
|
||||
onChange={setExtraTags}
|
||||
accentText="text-purple-400"
|
||||
accentChip="bg-purple-600 text-white border-purple-600"
|
||||
accentChipHover="hover:border-purple-500/60 hover:text-purple-400"
|
||||
rateUnits={pickRateUnits('project', 'day', 'hour')}
|
||||
/>
|
||||
|
||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-purple-400" entityCoord={user ? `${SKATE_EDITOR_KIND}:${user.pubkey}:editor-profile` : undefined} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } fr
|
|||
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(FILMING_SERVICE_LABELS);
|
||||
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
|
||||
|
|
@ -80,6 +82,7 @@ export default function BecomeFilmerPage() {
|
|||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState('');
|
||||
const [prefilled, setPrefilled] = useState(false);
|
||||
const [extraTags, setExtraTags] = useState<string[][]>([]);
|
||||
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
|
||||
|
||||
// Prefill from an existing profile so this is an edit, not a blind overwrite.
|
||||
|
|
@ -244,6 +247,7 @@ export default function BecomeFilmerPage() {
|
|||
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]);
|
||||
|
|
@ -580,6 +584,15 @@ export default function BecomeFilmerPage() {
|
|||
</div>
|
||||
|
||||
{/* Optional Pro upgrade — free to list without it */}
|
||||
<LabExtrasFields
|
||||
existingTags={existingProfile?.tags}
|
||||
onChange={setExtraTags}
|
||||
accentText="text-red-400"
|
||||
accentChip="bg-red-500 text-white border-red-500"
|
||||
accentChipHover="hover:border-red-500/50 hover:text-red-400"
|
||||
rateUnits={pickRateUnits('project', 'day', 'hour')}
|
||||
/>
|
||||
|
||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_FILMER_KIND}:${user.pubkey}:filming-profile` : undefined} />
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Ap
|
|||
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(PHOTO_SERVICE_LABELS);
|
||||
const CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS);
|
||||
|
|
@ -139,6 +141,7 @@ export default function BecomePhotographerPage() {
|
|||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState('');
|
||||
const [prefilled, setPrefilled] = useState(false);
|
||||
const [extraTags, setExtraTags] = useState<string[][]>([]);
|
||||
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
|
||||
|
||||
// Prefill from an existing profile so this is an edit, not a blind overwrite.
|
||||
|
|
@ -316,6 +319,7 @@ export default function BecomePhotographerPage() {
|
|||
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]);
|
||||
|
|
@ -646,6 +650,15 @@ export default function BecomePhotographerPage() {
|
|||
</div>
|
||||
|
||||
{/* Optional Pro upgrade — free to list without it */}
|
||||
<LabExtrasFields
|
||||
existingTags={existingProfile?.tags}
|
||||
onChange={setExtraTags}
|
||||
accentText="text-indigo-400"
|
||||
accentChip="bg-indigo-500 text-white border-indigo-500"
|
||||
accentChipHover="hover:border-indigo-500/60 hover:text-indigo-400"
|
||||
rateUnits={pickRateUnits('shoot', 'day', 'hour')}
|
||||
/>
|
||||
|
||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-indigo-400" entityCoord={user ? `${SKATE_PHOTOGRAPHER_KIND}:${user.pubkey}:photography-profile` : undefined} />
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
||||
import { useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { PageError } from '@/components/ErrorState';
|
||||
import { ImageLightbox } from '@/components/ImageLightbox';
|
||||
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||
import { parseCrewVideo } from '@/lib/videoEmbed';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -41,8 +37,9 @@ 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, COACH_PAYMENT_METHODS, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
|
||||
import { PayWithSats } from '@/components/PayWithSats';
|
||||
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, coachOptionKey, coachRateCurrencyKey, coachRateUnitKey, type CoachOption } from '@/lib/coachOptions';
|
||||
import { parseLabExtras } from '@/lib/labExtras';
|
||||
import { LabExtrasSection } from '@/components/LabExtrasSection';
|
||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { useTranslation } from '@/contexts/language';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
|
|
@ -73,13 +70,6 @@ import {
|
|||
Tent,
|
||||
Sparkles,
|
||||
ShieldCheck,
|
||||
Languages as LanguagesIcon,
|
||||
CalendarClock,
|
||||
Images,
|
||||
Clapperboard,
|
||||
ExternalLink,
|
||||
Banknote,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
|
|
@ -119,8 +109,6 @@ export default function CoachDetailPage() {
|
|||
const author = useAuthor(profile?.pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
|
||||
// Gigs — only relevant for musicians. The hook is disabled for other types.
|
||||
const { data: gigs = [] } = useGigs(
|
||||
|
|
@ -201,29 +189,17 @@ export default function CoachDetailPage() {
|
|||
const key = coachOptionKey(options, value);
|
||||
return key ? t(key) : value;
|
||||
};
|
||||
const galleryImages = parr('photos').map((url) => ({ url }));
|
||||
const videoEmbed = parseCrewVideo(pget('video'));
|
||||
// Shared craft-agnostic extras (languages, availability, rate, payments,
|
||||
// gallery, reel) — read straight off the event so every Lab craft gets them.
|
||||
const extras = parseLabExtras(profile.event);
|
||||
// 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 rateAmount = extras.rateAmount;
|
||||
const rateCurrencyKey = coachRateCurrencyKey(extras.rateCurrency);
|
||||
const rateUnitKey = coachRateUnitKey(extras.rateUnit);
|
||||
const rateDisplay = rateAmount
|
||||
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (pget('rateCurrency') ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
|
||||
? `${rateAmount} ${rateCurrencyKey ? t(rateCurrencyKey) : (extras.rateCurrency ?? '')}${rateUnitKey ? ` · ${t(rateUnitKey)}` : ''}`.trim()
|
||||
: pget('rate');
|
||||
|
||||
// Declared payment methods + optional booking fee (payable only in sats, and
|
||||
// only when the coach has a Lightning address to receive it).
|
||||
const paymentMethods = parr('paymentMethods');
|
||||
const bookingFee = pget('bookingFee');
|
||||
const bookingFeeCurrency = pget('bookingFeeCurrency') ?? 'sats';
|
||||
const bookingFeeCurrencyKey = coachRateCurrencyKey(bookingFeeCurrency);
|
||||
const bookingFeeDisplay = bookingFee
|
||||
? `${bookingFee} ${bookingFeeCurrencyKey ? t(bookingFeeCurrencyKey) : bookingFeeCurrency}`.trim()
|
||||
: undefined;
|
||||
const bookingFeeSats = bookingFee && bookingFeeCurrency === 'sats' ? parseInt(bookingFee, 10) : NaN;
|
||||
const canPayBookingFee = Number.isFinite(bookingFeeSats) && bookingFeeSats > 0 && !!author.data?.metadata?.lud16?.trim();
|
||||
|
||||
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
|
||||
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
|
||||
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
|
||||
|
|
@ -425,7 +401,7 @@ export default function CoachDetailPage() {
|
|||
)}
|
||||
|
||||
{/* Coach — credentials, formats, levels, ages, languages, availability */}
|
||||
{isCoach && (parr('credentials').length > 0 || pget('certNote') || parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0 || parr('languages').length > 0 || parr('availability').length > 0 || pget('booking')) && (
|
||||
{isCoach && (parr('credentials').length > 0 || pget('certNote') || parr('formats').length > 0 || parr('levels').length > 0 || parr('ageGroups').length > 0) && (
|
||||
<div className="mb-6 space-y-5">
|
||||
{(parr('credentials').length > 0 || pget('certNote')) && (
|
||||
<div>
|
||||
|
|
@ -484,153 +460,12 @@ export default function CoachDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{parr('languages').length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<LanguagesIcon className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.languages')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parr('languages').map((l) => (
|
||||
<span key={l} className={`text-xs px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(parr('availability').length > 0 || pget('booking')) && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
|
||||
<CalendarClock className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.availability')}
|
||||
</h3>
|
||||
{parr('availability').length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parr('availability').map((v) => (
|
||||
<span key={v} className={`text-xs px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>{optLabel(COACH_AVAILABILITY, v)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sanitizeUrl(pget('booking')) && (
|
||||
<a
|
||||
href={sanitizeUrl(pget('booking'))!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`mt-3 inline-flex items-center gap-1.5 text-sm font-bold px-4 py-2 rounded-full ${accent.bg} text-white hover:opacity-90`}
|
||||
>
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
{t('labUi.detail.bookSession')}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coach — portfolio gallery & highlight reel */}
|
||||
{isCoach && (galleryImages.length > 0 || videoEmbed) && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||
<Images className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.portfolio')}
|
||||
</h3>
|
||||
{galleryImages.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{galleryImages.map((img, i) => (
|
||||
<button
|
||||
key={img.url}
|
||||
type="button"
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 group"
|
||||
>
|
||||
<ImageWithFallback src={img.url} alt="" className="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{videoEmbed && (
|
||||
<a
|
||||
href={videoEmbed.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-full ${accent.bg} text-white font-bold text-sm hover:opacity-90`}
|
||||
>
|
||||
<Clapperboard className="h-4 w-4" />
|
||||
{t('labUi.detail.watchReel', { platform: videoEmbed.source === 'youtube' ? 'YouTube' : 'Instagram' })}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<ImageLightbox
|
||||
images={galleryImages}
|
||||
index={lightboxIndex}
|
||||
onIndexChange={setLightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payments — declared methods + optional direct booking fee */}
|
||||
{isCoach && (paymentMethods.length > 0 || pbool('payWhatYouCan') || bookingFee || pget('paymentNote')) && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||
<Banknote className={`h-4 w-4 ${accent.text}`} /> {t('labUi.detail.paymentsTitle')}
|
||||
</h3>
|
||||
|
||||
{(paymentMethods.length > 0 || pbool('payWhatYouCan')) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{paymentMethods.map((m) => {
|
||||
const key = coachOptionKey(COACH_PAYMENT_METHODS, m);
|
||||
return (
|
||||
<span key={m} className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
|
||||
{key ? t(key) : m}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{pbool('payWhatYouCan') && (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-bold px-3 py-1.5 rounded-full border ${accent.border} text-gray-200`}>
|
||||
{t('labUi.detail.payWhatYouCan')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pget('paymentNote') && (
|
||||
<p className="mt-2 text-sm text-gray-400">{pget('paymentNote')}</p>
|
||||
)}
|
||||
|
||||
{bookingFee && (
|
||||
<div className="mt-3 rounded-lg border border-white/10 bg-black/40 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">{t('labUi.detail.bookingFee')}</p>
|
||||
<p className="text-sm font-bold text-white">{bookingFeeDisplay}</p>
|
||||
</div>
|
||||
{canPayBookingFee && user?.pubkey !== profile.pubkey && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setPayOpen(true)}
|
||||
className={`gap-1.5 ${accent.bg} text-white font-bold hover:opacity-90`}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
{t('labUi.detail.payBookingFee')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-500">{t('labUi.detail.bookingFeeNote')}</p>
|
||||
{!canPayBookingFee && (
|
||||
<p className="mt-1 text-[11px] text-gray-500">{t('labUi.detail.bookingFeeNoRail')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payOpen && canPayBookingFee && (
|
||||
<PayWithSats
|
||||
open={payOpen}
|
||||
onOpenChange={setPayOpen}
|
||||
sellerPubkey={profile.pubkey}
|
||||
amountSats={bookingFeeSats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Shared extras (all crafts): payments, languages, availability, portfolio */}
|
||||
<LabExtrasSection extras={extras} accent={accent} profilePubkey={profile.pubkey} />
|
||||
|
||||
{/* In the Bag — photographer / filmer gear (freeform) */}
|
||||
{(isPhotographer || isFilmer || isBuilder) && pget('gear') && (
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)[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<string>();
|
||||
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<CoachFacetKey, string[]>;
|
||||
}, [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 =
|
||||
|
|
|
|||
Loading…
Reference in a new issue