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.
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|