1063 lines
48 KiB
TypeScript
1063 lines
48 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { useNavigate, Link } from 'react-router-dom';
|
|
import ngeohash from 'ngeohash';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useWallet } from '@/hooks/useWallet';
|
|
import { WalletModal } from '@/components/WalletModal';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { Nip05Required } from '@/components/Nip05Required';
|
|
import { SpotPicker } from '@/components/SpotPicker';
|
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
|
import { NofferField } from '@/components/NofferField';
|
|
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
|
import { CrewLinkField } from '@/components/CrewLinkField';
|
|
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
|
|
import { COACH_SKILL_KEYS, COACH_SKILL_DESC_KEYS } from '@/lib/labelKeys';
|
|
import { COACH_FORMATS, COACH_LEVELS, COACH_AGE_GROUPS, COACH_CREDENTIALS, COACH_AVAILABILITY, COACH_RATE_CURRENCIES, COACH_RATE_UNITS, COACH_PAYMENT_METHODS, type CoachOption } from '@/lib/coachOptions';
|
|
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { cn } from '@/lib/utils';
|
|
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Check, Eye } from 'lucide-react';
|
|
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
|
import { LabProfilePreview } from '@/components/LabProfilePreview';
|
|
import { LabDraftBanner } from '@/components/LabDraftBanner';
|
|
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
|
import { useLabDraft } from '@/hooks/useLabDraft';
|
|
import { labProfilePath } from '@/lib/labRoutes';
|
|
import { FormWizard, WizardStep } from '@/components/FormWizard';
|
|
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
|
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
|
|
|
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
|
|
|
|
/** Section divider/heading inside the (long) coach form. */
|
|
function FormSection({ title, hint }: { title: string; hint?: string }) {
|
|
return (
|
|
<div className="pt-4 border-t border-gray-800">
|
|
<h3 className="text-sm font-black uppercase tracking-widest text-red-400">{title}</h3>
|
|
{hint && <p className="text-xs text-gray-500 mt-0.5">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Reusable chip toggle row for the curated coach option sets. */
|
|
function OptionChips({
|
|
label,
|
|
hint,
|
|
options,
|
|
selected,
|
|
onToggle,
|
|
idPrefix,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
options: CoachOption[];
|
|
selected: string[];
|
|
onToggle: (value: string) => void;
|
|
idPrefix: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="space-y-2">
|
|
{label && <Label>{label}</Label>}
|
|
{hint && <p className="text-xs text-gray-500">{hint}</p>}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{options.map((o) => {
|
|
const active = selected.includes(o.value);
|
|
return (
|
|
<button
|
|
key={`${idPrefix}-${o.value}`}
|
|
type="button"
|
|
onClick={() => onToggle(o.value)}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
'min-h-0 min-w-0 leading-none px-3 py-2 rounded-full border text-xs font-bold transition-colors active:scale-95',
|
|
active
|
|
? 'bg-red-500 text-white border-red-500'
|
|
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300',
|
|
)}
|
|
>
|
|
{t(o.key)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function BecomeCoachPage() {
|
|
const { t } = useTranslation();
|
|
useSeoMeta({
|
|
title: t('seoUi.becomeCoachTitle'),
|
|
description: t('seoUi.becomeCoachDesc'),
|
|
});
|
|
|
|
const navigate = useNavigate();
|
|
const { user, metadata } = useCurrentUser();
|
|
const author = useAuthor(user?.pubkey || '');
|
|
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const { toast } = useToast();
|
|
const { webln, activeNWC } = useWallet();
|
|
|
|
// Check if user has any wallet connected
|
|
const hasWallet = !!webln || !!activeNWC;
|
|
|
|
// Optional "Pro" upgrade — free to list; Pro adds a badge + featured placement
|
|
const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
|
|
const [goPro, setGoPro] = useState(false);
|
|
|
|
const [bio, setBio] = useState('');
|
|
const [location, setLocation] = useState('');
|
|
const [latitude, setLatitude] = useState('');
|
|
const [longitude, setLongitude] = useState('');
|
|
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
|
const [image, setImage] = useState<string | null>(null);
|
|
const [experience, setExperience] = useState('');
|
|
const [rate, setRate] = useState('');
|
|
const [rateAmount, setRateAmount] = useState('');
|
|
const [rateCurrency, setRateCurrency] = useState<string>('sats');
|
|
const [rateUnit, setRateUnit] = useState<string>('hour');
|
|
const [paymentMethods, setPaymentMethods] = useState<string[]>([]);
|
|
const [paymentNote, setPaymentNote] = useState('');
|
|
const [bookingFee, setBookingFee] = useState('');
|
|
const [bookingFeeCurrency, setBookingFeeCurrency] = useState<string>('sats');
|
|
const [payWhatYouCan, setPayWhatYouCan] = useState(false);
|
|
const [phone, setPhone] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [website, setWebsite] = useState('');
|
|
const [instagram, setInstagram] = useState('');
|
|
const [noffer, setNoffer] = useState('');
|
|
const [formats, setFormats] = useState<string[]>([]);
|
|
const [levels, setLevels] = useState<string[]>([]);
|
|
const [ageGroups, setAgeGroups] = useState<string[]>([]);
|
|
const [credentials, setCredentials] = useState<string[]>([]);
|
|
const [certNote, setCertNote] = useState('');
|
|
const [languages, setLanguages] = useState<string[]>([]);
|
|
const [languageInput, setLanguageInput] = useState('');
|
|
const [availability, setAvailability] = useState<string[]>([]);
|
|
const [booking, setBooking] = useState('');
|
|
const [photos, setPhotos] = useState<string[]>([]);
|
|
const [video, setVideo] = useState('');
|
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
|
const [members, setMembers] = useState<string[]>([]);
|
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
|
const [titleInput, setTitleInput] = useState('');
|
|
const [prefilled, setPrefilled] = useState(false);
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [step, setStep] = useState(0);
|
|
|
|
const draft = useLabDraft(
|
|
`craft:coach`,
|
|
{ titleInput, bio, location, latitude, longitude, selectedSkills, image, experience, rate, rateAmount, rateCurrency, rateUnit, phone, email, website, instagram, noffer, entityType, members, crewRef, selectedSpots, formats, levels, ageGroups, credentials, certNote, languages, availability, booking, paymentMethods, paymentNote, bookingFee, bookingFeeCurrency, payWhatYouCan, photos, video },
|
|
(d) => {
|
|
setTitleInput(d.titleInput);
|
|
setBio(d.bio);
|
|
setLocation(d.location);
|
|
setLatitude(d.latitude);
|
|
setLongitude(d.longitude);
|
|
setSelectedSkills(d.selectedSkills);
|
|
setImage(d.image);
|
|
setExperience(d.experience);
|
|
setRate(d.rate);
|
|
setRateAmount(d.rateAmount);
|
|
setRateCurrency(d.rateCurrency);
|
|
setRateUnit(d.rateUnit);
|
|
setPhone(d.phone);
|
|
setEmail(d.email);
|
|
setWebsite(d.website);
|
|
setInstagram(d.instagram);
|
|
setNoffer(d.noffer);
|
|
setEntityType(d.entityType);
|
|
setMembers(d.members);
|
|
setCrewRef(d.crewRef);
|
|
setSelectedSpots(d.selectedSpots);
|
|
setFormats(d.formats);
|
|
setLevels(d.levels);
|
|
setAgeGroups(d.ageGroups);
|
|
setCredentials(d.credentials);
|
|
setCertNote(d.certNote);
|
|
setLanguages(d.languages);
|
|
setAvailability(d.availability);
|
|
setBooking(d.booking);
|
|
setPaymentMethods(d.paymentMethods);
|
|
setPaymentNote(d.paymentNote);
|
|
setBookingFee(d.bookingFee);
|
|
setBookingFeeCurrency(d.bookingFeeCurrency);
|
|
setPayWhatYouCan(d.payWhatYouCan);
|
|
setPhotos(d.photos);
|
|
setVideo(d.video);
|
|
},
|
|
);
|
|
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(undefined);
|
|
|
|
// Prefill from an existing profile so this is an edit, not a blind overwrite.
|
|
const { data: existingProfile } = useMyLabProfileEvent(SKATE_COACH_KIND, 'coach-profile');
|
|
useEffect(() => {
|
|
if (prefilled || !existingProfile) return;
|
|
setPrefilled(true);
|
|
const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1];
|
|
const tags = (n: string) => existingProfile.tags.filter(([t]) => t === n).map(([, v]) => v);
|
|
setTitleInput(tag('title') ?? '');
|
|
setExistingGeohash(tag('g'));
|
|
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
|
|
setBio(existingProfile.content ?? '');
|
|
setLocation(tag('location') ?? '');
|
|
setImage(tag('image') ?? null);
|
|
setExperience(tag('experience') ?? '');
|
|
setRate(tag('rate') ?? '');
|
|
setRateAmount(tag('rate_amount') ?? '');
|
|
setRateCurrency(tag('rate_currency') ?? 'sats');
|
|
setRateUnit(tag('rate_unit') ?? 'hour');
|
|
setPaymentMethods(tags('payment_method'));
|
|
setPaymentNote(tag('payment_note') ?? '');
|
|
setBookingFee(tag('booking_fee') ?? '');
|
|
setBookingFeeCurrency(tag('booking_fee_currency') ?? 'sats');
|
|
setPayWhatYouCan(existingProfile.tags.some(([t, v]) => t === 'pay_what_you_can' && v === 'true'));
|
|
setPhone(tag('phone') ?? '');
|
|
setEmail(tag('email') ?? '');
|
|
setWebsite(tag('website') ?? '');
|
|
setInstagram(tag('instagram') ?? '');
|
|
setNoffer(tag('noffer') ?? '');
|
|
setFormats(tags('format'));
|
|
setLevels(tags('level'));
|
|
setAgeGroups(tags('age'));
|
|
setCredentials(tags('cert'));
|
|
setCertNote(tag('cert_note') ?? '');
|
|
setLanguages(tags('language'));
|
|
setAvailability(tags('availability'));
|
|
setBooking(tag('booking') ?? '');
|
|
setPhotos(tags('photo'));
|
|
setVideo(tag('video') ?? '');
|
|
setEntityType((tag('entity') as EntityType) ?? 'individual');
|
|
setMembers(tags('p'));
|
|
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]);
|
|
setSelectedSkills(tags('t').filter((v) => v !== 'skatecoach'));
|
|
setSelectedSpots(
|
|
existingProfile.tags
|
|
.filter(([t, v]) => t === 'a' && v.startsWith(`${SKATE_SPOT_KIND}:`))
|
|
.map(([, v]) => {
|
|
const [, pk, ...rest] = v.split(':');
|
|
return `${pk}:${rest.join(':')}`;
|
|
}),
|
|
);
|
|
}, [existingProfile, prefilled]);
|
|
|
|
const getDisplayName = () => {
|
|
if (author.data?.metadata?.name) return author.data.metadata.name;
|
|
if (user?.pubkey) {
|
|
const npub = nip19.npubEncode(user.pubkey);
|
|
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
|
|
}
|
|
return t('labUi.coachForm.anonymous');
|
|
};
|
|
|
|
const toggleSkill = (skill: string) => {
|
|
setSelectedSkills((prev) => prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]);
|
|
};
|
|
|
|
const toggleValue = (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 getCurrentLocation = () => {
|
|
if (!navigator.geolocation) {
|
|
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
setIsGettingLocation(true);
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
setLatitude(position.coords.latitude.toFixed(6));
|
|
setLongitude(position.coords.longitude.toFixed(6));
|
|
setIsGettingLocation(false);
|
|
toast({ title: t('labUi.become.locationFound') });
|
|
},
|
|
(error) => {
|
|
setIsGettingLocation(false);
|
|
toast({ title: t('labUi.become.locationError'), description: error.message, variant: 'destructive' });
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
try {
|
|
const file = files[0];
|
|
const tags = await uploadFile(file);
|
|
const url = tags[0]?.[1];
|
|
if (url) {
|
|
setImage(url);
|
|
toast({ title: t('labUi.become.imageUploaded') });
|
|
}
|
|
} catch (error) {
|
|
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
|
}
|
|
e.target.value = '';
|
|
};
|
|
|
|
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' });
|
|
}
|
|
};
|
|
|
|
// Optional, but if given it must be usable — a bad email/URL would otherwise
|
|
// be stored and then silently hidden.
|
|
const emailError = email.trim() && !isValidEmail(email) ? t('labUi.become.invalidEmail') : '';
|
|
const websiteError = website.trim() && !sanitizeUrl(normalizeUrlInput(website)) ? t('labUi.become.invalidWebsite') : '';
|
|
const contactError = emailError || websiteError;
|
|
const [attempted, setAttempted] = useState(false);
|
|
const scrollTo = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
// Draft enrichment tags for the live preview (same tags the form publishes).
|
|
const previewExtraTags: string[][] = [];
|
|
for (const v of languages) previewExtraTags.push(['language', v]);
|
|
for (const v of availability) previewExtraTags.push(['availability', v]);
|
|
if (rateAmount.trim()) {
|
|
previewExtraTags.push(['rate_amount', rateAmount.trim()], ['rate_currency', rateCurrency], ['rate_unit', rateUnit]);
|
|
}
|
|
for (const m of paymentMethods) previewExtraTags.push(['payment_method', m]);
|
|
if (paymentNote.trim()) previewExtraTags.push(['payment_note', paymentNote.trim()]);
|
|
if (bookingFee.trim()) previewExtraTags.push(['booking_fee', bookingFee.trim()], ['booking_fee_currency', bookingFeeCurrency]);
|
|
if (payWhatYouCan) previewExtraTags.push(['pay_what_you_can', 'true']);
|
|
for (const p of photos) previewExtraTags.push(['photo', p]);
|
|
if (video.trim()) previewExtraTags.push(['video', video.trim()]);
|
|
if (booking.trim()) previewExtraTags.push(['booking', booking.trim()]);
|
|
const previewSkills = SKILL_ENTRIES
|
|
.filter(([skill]) => selectedSkills.includes(skill))
|
|
.map(([skill, label]) => ({ label: COACH_SKILL_KEYS[skill] ? t(COACH_SKILL_KEYS[skill]) : label }));
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setAttempted(true);
|
|
|
|
if (contactError) {
|
|
setStep(4);
|
|
window.setTimeout(() => scrollTo(emailError ? 'email' : 'website'), 60);
|
|
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
if (selectedSkills.length === 0) {
|
|
setStep(1);
|
|
window.setTimeout(() => scrollTo('coach-skills'), 60);
|
|
toast({ title: t('labUi.coachForm.skillsRequired'), description: t('labUi.coachForm.selectSkill'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const identifier = 'coach-profile';
|
|
const displayName = getDisplayName();
|
|
|
|
const tags: string[][] = [
|
|
['d', identifier],
|
|
['title', titleInput.trim() || displayName],
|
|
['t', 'skatecoach'],
|
|
['alt', `Skate coach: ${displayName}`],
|
|
];
|
|
|
|
if (location.trim()) tags.push(['location', location.trim()]);
|
|
|
|
if (latitude && longitude) {
|
|
const lat = parseFloat(latitude);
|
|
const lng = parseFloat(longitude);
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
const geohash = ngeohash.encode(lat, lng, 7);
|
|
tags.push(['g', geohash]);
|
|
}
|
|
} else if (existingGeohash) {
|
|
// Preserve the existing map pin when editing without re-locating.
|
|
tags.push(['g', existingGeohash]);
|
|
}
|
|
|
|
for (const skill of selectedSkills) tags.push(['t', skill]);
|
|
if (image) tags.push(['image', image]);
|
|
if (experience.trim()) tags.push(['experience', experience.trim()]);
|
|
if (rate.trim()) tags.push(['rate', rate.trim()]);
|
|
if (rateAmount.trim()) {
|
|
tags.push(['rate_amount', rateAmount.trim()]);
|
|
tags.push(['rate_currency', rateCurrency]);
|
|
tags.push(['rate_unit', rateUnit]);
|
|
}
|
|
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']);
|
|
if (phone.trim()) tags.push(['phone', phone.trim()]);
|
|
if (email.trim()) tags.push(['email', email.trim()]);
|
|
if (website.trim()) tags.push(['website', normalizeUrlInput(website)]);
|
|
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
|
|
|
for (const v of formats) tags.push(['format', v]);
|
|
for (const v of levels) tags.push(['level', v]);
|
|
for (const v of ageGroups) tags.push(['age', v]);
|
|
for (const v of credentials) tags.push(['cert', v]);
|
|
if (certNote.trim()) tags.push(['cert_note', certNote.trim()]);
|
|
for (const v of languages) tags.push(['language', v]);
|
|
for (const v of availability) tags.push(['availability', v]);
|
|
if (booking.trim()) tags.push(['booking', normalizeUrlInput(booking)]);
|
|
for (const p of photos) tags.push(['photo', p]);
|
|
if (video.trim()) tags.push(['video', video.trim()]);
|
|
|
|
tags.push(['entity', entityType]);
|
|
if (entityType === 'collective') {
|
|
for (const pk of members) tags.push(['p', pk]);
|
|
if (crewRef) tags.push(['a', crewRef]);
|
|
}
|
|
|
|
// Add spot references
|
|
for (const spotId of selectedSpots) {
|
|
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
|
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
|
|
}
|
|
|
|
// Pro / featured listing (optional, paid)
|
|
if (isPro && featuredUntilSeconds) {
|
|
tags.push(['featured', String(featuredUntilSeconds)]);
|
|
}
|
|
|
|
await createEvent({
|
|
kind: SKATE_COACH_KIND,
|
|
content: bio.trim(),
|
|
tags,
|
|
});
|
|
draft.clear();
|
|
|
|
toast({ title: existingProfile ? t('labUi.coachForm.updated') : t('labUi.coachForm.created'), description: t('labUi.become.profilePublished') });
|
|
navigate(`${labProfilePath('coach', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
|
|
} catch (error) {
|
|
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-8">
|
|
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
|
<CardContent className="py-12 text-center">
|
|
<GraduationCap className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
|
<h2 className="text-xl font-bold mb-2">{t('labUi.become.loginRequired')}</h2>
|
|
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDesc', { role: t('labUi.role.coach') })}</p>
|
|
<LoginArea className="justify-center max-w-60 mx-auto" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const hasNip05 = metadata?.nip05;
|
|
|
|
return (
|
|
<>
|
|
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
|
<div className="mb-6">
|
|
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
{t('labUi.become.backToLab')}
|
|
</Link>
|
|
<h1 className="text-3xl font-black uppercase neon-lime" style={{
|
|
fontFamily: 'Impact, "Arial Black", sans-serif'
|
|
}}>{t('labUi.coachForm.heading')}</h1>
|
|
<p className="text-gray-400 mt-1">{t('labUi.coachForm.tagline')}</p>
|
|
|
|
{!hasNip05 && (
|
|
<Nip05Required
|
|
variant="banner"
|
|
color="#eab308"
|
|
role="coaches"
|
|
reason={t('labUi.coachForm.nip05Reason')}
|
|
className="mt-4"
|
|
/>
|
|
)}
|
|
|
|
{/* Wallet Requirement */}
|
|
<Card className="mt-4 bg-orange-950/30 border-2 border-orange-500/50">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<h3 className="font-bold text-orange-400 mb-2">{t('labUi.become.paymentSetup')}</h3>
|
|
<p className="text-sm text-gray-300 mb-3">
|
|
{t('labUi.coachForm.paymentDesc')}
|
|
</p>
|
|
|
|
{hasWallet ? (
|
|
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<div className="h-2 w-2 rounded-full bg-green-500" />
|
|
<p className="text-sm font-bold text-green-400">{t('labUi.become.walletConnected')}</p>
|
|
</div>
|
|
<p className="text-xs text-gray-400">
|
|
{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-2 w-2 rounded-full bg-red-500" />
|
|
<p className="text-sm font-bold text-red-400">{t('labUi.become.noWallet')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<WalletModal>
|
|
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1">
|
|
<Wallet className="h-4 w-4" />
|
|
{t('labUi.become.connectWalletNwc')}
|
|
</Button>
|
|
</WalletModal>
|
|
<a
|
|
href="https://planb.academy/en/tutorials/wallet"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex-1"
|
|
>
|
|
<Button variant="outline" className="gap-2 w-full border-orange-500/50 text-orange-400 hover:bg-orange-500/10">
|
|
<ExternalLink className="h-4 w-4" />
|
|
{t('labUi.become.learnHow')}
|
|
</Button>
|
|
</a>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-400 italic">
|
|
{t('labUi.become.tipWithTime')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
|
|
<LabProfileProgress
|
|
hasPhoto={!!image}
|
|
hasBio={bio.trim().length > 20}
|
|
hasSkills={selectedSkills.length > 0}
|
|
hasRate={!!(rate.trim() || rateAmount.trim())}
|
|
hasContact={!!(email || website || phone || instagram)}
|
|
hasLocation={!!location.trim()}
|
|
/>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<Card className="bg-gray-900 border-gray-700">
|
|
<CardHeader>
|
|
<CardTitle>{t('labUi.coachForm.profileTitle')}</CardTitle>
|
|
<CardDescription className="text-gray-400">{t('labUi.coachForm.profileDesc')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<FormWizard
|
|
steps={[
|
|
{ id: 'identity', title: t('labUi.wizard.identity') },
|
|
{ id: 'coach', title: t('labUi.coachForm.sectionWhatYouCoach') },
|
|
{ id: 'creds', title: t('labUi.coachForm.sectionCredentials') },
|
|
{ id: 'pay', title: t('labUi.coachForm.sectionPayments') },
|
|
{ id: 'where', title: t('labUi.coachForm.sectionWhereYouCoach') },
|
|
]}
|
|
step={step}
|
|
onStepChange={setStep}
|
|
accentChip="bg-red-500 text-white border-red-500"
|
|
submitting={isPublishing || isUploading}
|
|
submitLabel={existingProfile ? t('labUi.coachForm.update') : isPro ? t('labUi.coachForm.createPro') : t('labUi.coachForm.create')}
|
|
submittingLabel={t('labUi.become.publishing')}
|
|
footerExtra={
|
|
<Button type="button" variant="outline" onClick={() => setPreviewOpen(true)} className="h-12 flex-shrink-0 gap-2 border-gray-700 font-bold">
|
|
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
|
|
</Button>
|
|
}
|
|
>
|
|
<WizardStep index={0}>
|
|
<div className="space-y-2">
|
|
<Label>{t('labUi.coachForm.identity')}</Label>
|
|
<div className="flex items-center gap-3 p-4 rounded-lg border border-gray-700 bg-gray-800/50">
|
|
<div className="h-12 w-12 rounded-full overflow-hidden bg-gray-700 flex items-center justify-center flex-shrink-0">
|
|
{metadata?.picture ? (
|
|
<img src={metadata.picture} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<GraduationCap className="h-6 w-6 text-gray-500" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="font-black text-lg truncate">{getDisplayName()}</p>
|
|
<p className="text-sm text-gray-400">{t('labUi.become.fromNostrProfile')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
|
<Input
|
|
id="title"
|
|
value={titleInput}
|
|
onChange={(e) => setTitleInput(e.target.value)}
|
|
placeholder={getDisplayName()}
|
|
maxLength={100}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<p className="text-xs text-gray-500">
|
|
{t('labUi.become.headlineHint', { example: t('labUi.coachForm.headlineExample') })}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bio">{t('labUi.coachForm.bioLabel')}</Label>
|
|
<Textarea id="bio" placeholder={t('labUi.coachForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
|
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
|
</div>
|
|
|
|
<EntityTypeField
|
|
entityType={entityType}
|
|
onEntityTypeChange={setEntityType}
|
|
members={members}
|
|
onMembersChange={setMembers}
|
|
accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90"
|
|
accentText="text-[#ff0040]"
|
|
/>
|
|
|
|
{entityType === 'collective' && (
|
|
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90" accentText="text-[#ff0040]" />
|
|
)}
|
|
|
|
</WizardStep>
|
|
<WizardStep index={1}>
|
|
<FormSection title={t('labUi.coachForm.sectionWhatYouCoach')} />
|
|
|
|
<div className="space-y-3">
|
|
<Label>{t('labUi.coachForm.skillsLabel')}</Label>
|
|
<div id="coach-skills" className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{SKILL_ENTRIES.map(([skill, label]) => (
|
|
<button
|
|
key={skill}
|
|
type="button"
|
|
onClick={() => toggleSkill(skill)}
|
|
className={cn(
|
|
'p-3 rounded-lg text-left transition-all border text-sm',
|
|
selectedSkills.includes(skill)
|
|
? 'bg-red-500 text-white border-red-500'
|
|
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
|
|
)}
|
|
>
|
|
<div className="font-bold">{COACH_SKILL_KEYS[skill] ? t(COACH_SKILL_KEYS[skill]) : label}</div>
|
|
<div className={cn('text-xs mt-1 line-clamp-1', selectedSkills.includes(skill) ? 'text-white/80' : 'text-gray-400')}>
|
|
{COACH_SKILL_DESC_KEYS[skill] ? t(COACH_SKILL_DESC_KEYS[skill]) : COACH_SKILL_DESCRIPTIONS[skill]}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{attempted && selectedSkills.length === 0 && (
|
|
<p className="text-xs font-bold text-red-400">{t('labUi.coachForm.selectSkill')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<OptionChips
|
|
label={t('labUi.coachForm.formatsLabel')}
|
|
hint={t('labUi.coachForm.formatsHint')}
|
|
options={COACH_FORMATS}
|
|
selected={formats}
|
|
onToggle={(v) => toggleValue(formats, setFormats, v)}
|
|
idPrefix="format"
|
|
/>
|
|
|
|
<OptionChips
|
|
label={t('labUi.coachForm.levelsLabel')}
|
|
options={COACH_LEVELS}
|
|
selected={levels}
|
|
onToggle={(v) => toggleValue(levels, setLevels, v)}
|
|
idPrefix="level"
|
|
/>
|
|
|
|
<OptionChips
|
|
label={t('labUi.coachForm.agesLabel')}
|
|
options={COACH_AGE_GROUPS}
|
|
selected={ageGroups}
|
|
onToggle={(v) => toggleValue(ageGroups, setAgeGroups, v)}
|
|
idPrefix="age"
|
|
/>
|
|
|
|
</WizardStep>
|
|
<WizardStep index={2}>
|
|
<FormSection title={t('labUi.coachForm.sectionCredentials')} />
|
|
|
|
<OptionChips
|
|
label=""
|
|
hint={t('labUi.coachForm.credentialsHint')}
|
|
options={COACH_CREDENTIALS}
|
|
selected={credentials}
|
|
onToggle={(v) => toggleValue(credentials, setCredentials, v)}
|
|
idPrefix="cert"
|
|
/>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="cert-note" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.certNoteLabel')}</Label>
|
|
<Input id="cert-note" placeholder={t('labUi.coachForm.certNotePlaceholder')} value={certNote} onChange={(e) => setCertNote(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="language">{t('labUi.coachForm.languagesLabel')}</Label>
|
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.languagesHint')}</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="language"
|
|
value={languageInput}
|
|
onChange={(e) => setLanguageInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addLanguage(); } }}
|
|
placeholder={t('labUi.coachForm.languagePlaceholder')}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<Button type="button" variant="outline" onClick={addLanguage} className="border-gray-700 shrink-0">
|
|
{t('common.add')}
|
|
</Button>
|
|
</div>
|
|
{languages.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{languages.map((lang) => (
|
|
<span key={lang} className="inline-flex items-center gap-1 rounded-full border border-gray-700 bg-gray-800 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>
|
|
|
|
<FormSection title={t('labUi.coachForm.sectionAvailability')} />
|
|
|
|
<OptionChips
|
|
label=""
|
|
options={COACH_AVAILABILITY}
|
|
selected={availability}
|
|
onToggle={(v) => toggleValue(availability, setAvailability, v)}
|
|
idPrefix="availability"
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="booking">{t('labUi.coachForm.bookingLabel')}</Label>
|
|
<Input
|
|
id="booking"
|
|
type="url"
|
|
value={booking}
|
|
onChange={(e) => setBooking(e.target.value)}
|
|
placeholder="https://cal.com/yourname"
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.bookingHint')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</Label>
|
|
<Input id="experience" type="number" placeholder="15" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Label>{t('labUi.coachForm.rateAmountLabel')}</Label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Input
|
|
type="number"
|
|
inputMode="numeric"
|
|
placeholder="5000"
|
|
value={rateAmount}
|
|
onChange={(e) => setRateAmount(e.target.value.replace(/[^0-9.]/g, ''))}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<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-gray-700 bg-gray-800 px-2 text-sm text-white"
|
|
>
|
|
{COACH_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-gray-700 bg-gray-800 px-2 text-sm text-white"
|
|
>
|
|
{COACH_RATE_UNITS.map((u) => (
|
|
<option key={u.value} value={u.value}>{t(u.key)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="rate" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.rateNotesLabel')}</Label>
|
|
<Input id="rate" placeholder={t('labUi.coachForm.rateNotesPlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
</div>
|
|
|
|
</WizardStep>
|
|
<WizardStep index={3}>
|
|
<FormSection title={t('labUi.coachForm.sectionPayments')} />
|
|
|
|
<OptionChips
|
|
label=""
|
|
hint={t('labUi.coachForm.paymentMethodsHint')}
|
|
options={COACH_PAYMENT_METHODS}
|
|
selected={paymentMethods}
|
|
onToggle={(v) => toggleValue(paymentMethods, setPaymentMethods, v)}
|
|
idPrefix="paymethod"
|
|
/>
|
|
|
|
<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-red-500 bg-red-500/10 text-red-300'
|
|
: 'border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700',
|
|
)}
|
|
>
|
|
<span className={cn('flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border', payWhatYouCan ? 'border-red-400 bg-red-500 text-white' : 'border-gray-600')}>
|
|
{payWhatYouCan && <Check className="h-3 w-3" />}
|
|
</span>
|
|
{t('labUi.coachForm.payWhatYouCanLabel')}
|
|
</button>
|
|
|
|
<div className="space-y-3">
|
|
<Label>{t('labUi.coachForm.bookingFeeLabel')}</Label>
|
|
<div className="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-gray-800 border-gray-700"
|
|
/>
|
|
<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-gray-700 bg-gray-800 px-2 text-sm text-white"
|
|
>
|
|
{COACH_RATE_CURRENCIES.map((c) => (
|
|
<option key={c.value} value={c.value}>{t(c.key)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.bookingFeeHint')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="payment-note" className="text-xs font-normal text-gray-400">{t('labUi.coachForm.paymentNoteLabel')}</Label>
|
|
<Input id="payment-note" placeholder={t('labUi.coachForm.paymentNotePlaceholder')} value={paymentNote} onChange={(e) => setPaymentNote(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
|
|
</WizardStep>
|
|
<WizardStep index={4}>
|
|
<FormSection title={t('labUi.coachForm.sectionWhereYouCoach')} />
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="location">{t('labUi.become.cityArea')}</Label>
|
|
<Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-bold">{t('labUi.become.contactInfo')}</Label>
|
|
<p className="text-sm text-gray-400">{t('labUi.become.contactInfoDesc')}</p>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">{t('labUi.become.phoneNumber')}</Label>
|
|
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t('labUi.become.email')}</Label>
|
|
<Input id="email" type="email" placeholder="coach@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className={cn('bg-gray-800 border-gray-700', emailError && 'border-red-500')} />
|
|
{emailError && <p className="text-xs text-red-400">{emailError}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="website">{t('labUi.become.website')}</Label>
|
|
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className={cn('bg-gray-800 border-gray-700', websiteError && 'border-red-500')} />
|
|
{websiteError && <p className="text-xs text-red-400">{websiteError}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="instagram">{t('labUi.become.instagram')}</Label>
|
|
<Input id="instagram" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
</div>
|
|
|
|
<PaymentReadinessNote />
|
|
<NofferField value={noffer} onChange={setNoffer} />
|
|
|
|
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
|
|
<p className="text-sm text-gray-300">
|
|
{t('labUi.coachForm.dmNote')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spot Picker */}
|
|
<SpotPicker
|
|
selectedSpots={selectedSpots}
|
|
onSpotsChange={setSelectedSpots}
|
|
maxSpots={5}
|
|
label={t('labUi.coachForm.spotsLabel')}
|
|
description={t('labUi.coachForm.spotsDesc')}
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('labUi.become.coordinatesOptional')}</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
|
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
|
</Button>
|
|
</div>
|
|
|
|
<FormSection title={t('labUi.coachForm.sectionPortfolio')} />
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('labUi.become.profilePhoto')}</Label>
|
|
{image ? (
|
|
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
|
|
<img src={image} alt={t('legacyMiscUi.altProfile')} className="w-full h-full object-cover" />
|
|
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<label className="block w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
|
|
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
|
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">{t('labUi.become.upload')}</span></>}
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<Label>{t('labUi.coachForm.galleryLabel')}</Label>
|
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.galleryHint')}</p>
|
|
{photos.length > 0 && (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
|
{photos.map((url) => (
|
|
<div key={url} className="relative aspect-square rounded-lg overflow-hidden bg-gray-800">
|
|
<ImageWithFallback src={url} alt="" className="h-full w-full object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={() => setPhotos((prev) => prev.filter((p) => p !== url))}
|
|
className="absolute top-1 right-1 p-1 rounded-full bg-black/80 hover:bg-red-500"
|
|
aria-label={t('common.remove')}
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{photos.length < 12 && (
|
|
<PhotoDropzone
|
|
onFiles={handleGalleryUpload}
|
|
uploading={isUploading}
|
|
disabled={isPublishing}
|
|
className="aspect-video"
|
|
label={t('labUi.coachForm.galleryAdd')}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="video">{t('labUi.coachForm.videoLabel')}</Label>
|
|
<Input
|
|
id="video"
|
|
type="url"
|
|
value={video}
|
|
onChange={(e) => setVideo(e.target.value)}
|
|
placeholder="https://youtube.com/watch?v=…"
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<p className="text-xs text-gray-500">{t('labUi.coachForm.videoHint')}</p>
|
|
</div>
|
|
|
|
{/* Optional Pro upgrade — free to list without it */}
|
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />
|
|
|
|
</WizardStep>
|
|
</FormWizard>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</div>
|
|
|
|
<LabProfilePreview
|
|
open={previewOpen}
|
|
onOpenChange={setPreviewOpen}
|
|
heading={t('labUi.coachForm.profileTitle')}
|
|
noun={t('labUi.detail.noun.coach')}
|
|
accentText="text-red-400"
|
|
accentChip="bg-red-500 text-white border-red-500"
|
|
icon={GraduationCap}
|
|
draft={{
|
|
title: titleInput.trim() || getDisplayName(),
|
|
bio,
|
|
image,
|
|
location,
|
|
experience,
|
|
rate,
|
|
skills: previewSkills,
|
|
extraTags: previewExtraTags,
|
|
contact: { phone, email, website, instagram, noffer },
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|