SkateSpots/src/components/LabExtrasFields.tsx
SkateSpots 88a3e4d1b4 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.
2026-10-04 10:49:48 +07:00

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>
);
}