- useDirectMessages: correct nip44 getConversationKey (Uint8Array) + finalizeEvent EventTemplate (remove pubkey/unused ephemeralPubkey/bytesToHex)
- Access current-user metadata via useCurrentUser() spread instead of user.metadata across ~10 files (rename to userMetadata where author metadata already bound)
- useCrewGoal: explicit UseQueryResult<CrewGoal|null> return type (fixes CrewForm/CrewTreasuryCard {} inference)
- New useCommentCounts hook; fix EventsPage/MapPage misuse of useComments(string[])
- CoachDetailPage: typed pget/pbool accessors for craft-specific union fields
- MapPage: shop entries include tags/category to satisfy SkateSpot
- SkateGlobe: read event.tags; LabPage: remove duplicate serviceType; LabCrewsStrip: add 'judging'
- Icon prop widened to accept style; ReportDialog subtype guard; SecuritySettings/TourCard/EventsPage comparisons; CashuWallet onClick; ConversationPage recipients; useLoggedInAccounts NLoginType import; LicensePage icon; TourDetailPage ZapButton target + real npub link; TestApp useSkateRelay
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
494 lines
22 KiB
TypeScript
494 lines
22 KiB
TypeScript
import { useState } from 'react';
|
||
import { useNavigate, Link } from 'react-router-dom';
|
||
import ngeohash from 'ngeohash';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { Layout } from '@/components/Layout';
|
||
import { Card, CardContent } 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 { Switch } from '@/components/ui/switch';
|
||
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 { LoginArea } from '@/components/auth/LoginArea';
|
||
import { NofferField } from '@/components/NofferField';
|
||
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||
import { EntityTypeField } from '@/components/EntityTypeField';
|
||
import { CrewLinkField } from '@/components/CrewLinkField';
|
||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||
import { isValidNoffer, type EntityType } from '@/lib/types';
|
||
import { cn } from '@/lib/utils';
|
||
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
|
||
|
||
/** A group of selectable tag chips (e.g. "Genre", "Instruments"). */
|
||
export interface SkillSection {
|
||
title: string;
|
||
/** [tagValue, label] pairs. */
|
||
entries: [string, string][];
|
||
/** If true, at least one must be selected before publishing. */
|
||
required?: boolean;
|
||
}
|
||
|
||
/** A freeform extra field specific to a craft (e.g. tattoo studio, music links). */
|
||
export interface ExtraField {
|
||
/** Nostr tag name to write, e.g. "studio", "spotify". */
|
||
tag: string;
|
||
label: string;
|
||
placeholder?: string;
|
||
/** "text" (default) | "url" | "toggle" */
|
||
type?: 'text' | 'url' | 'toggle';
|
||
helper?: string;
|
||
}
|
||
|
||
export interface CraftFormConfig {
|
||
kind: number;
|
||
selfTag: string;
|
||
/** Fixed `d` identifier for this craft's single profile per user. */
|
||
identifier: string;
|
||
/** Page heading, e.g. "Become a Tattoo Artist". */
|
||
heading: string;
|
||
/** Sub-line under the heading. */
|
||
tagline: string;
|
||
/** Human noun for toasts / alt text, e.g. "tattoo artist". */
|
||
noun: string;
|
||
/** Tailwind text color for the accent, e.g. "text-rose-400". */
|
||
accentText: string;
|
||
/** Tailwind bg color for the accent, e.g. "bg-rose-600". */
|
||
accentBg: string;
|
||
/** Active-chip classes, e.g. "bg-rose-600 text-white border-rose-600". */
|
||
accentChip: string;
|
||
/** Hover classes for inactive chips. */
|
||
accentChipHover: string;
|
||
/** Icon component for the login-gate + header. */
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
/** Tag chip sections. */
|
||
skillSections: SkillSection[];
|
||
/** Craft-specific freeform fields. */
|
||
extraFields?: ExtraField[];
|
||
/** Whether this craft can be a collective (default true). */
|
||
allowCollective?: boolean;
|
||
/** Whether to show the rate field (default true). */
|
||
showRate?: boolean;
|
||
/** Label for the rate field. */
|
||
rateLabel?: string;
|
||
/** Label for the portfolio field. */
|
||
portfolioLabel?: string;
|
||
}
|
||
|
||
export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
|
||
const {
|
||
kind, selfTag, identifier, heading, tagline, noun,
|
||
accentText, accentBg, accentChip, accentChipHover, icon: Icon,
|
||
skillSections, extraFields = [], allowCollective = true,
|
||
showRate = true, rateLabel = 'Rate', portfolioLabel = 'Portfolio Link',
|
||
} = config;
|
||
|
||
const navigate = useNavigate();
|
||
const { user, metadata } = useCurrentUser();
|
||
const author = useAuthor(user?.pubkey || '');
|
||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||
const { toast } = useToast();
|
||
|
||
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 [selected, setSelected] = useState<string[]>([]);
|
||
const [image, setImage] = useState<string | null>(null);
|
||
const [experience, setExperience] = useState('');
|
||
const [rate, setRate] = useState('');
|
||
const [phone, setPhone] = useState('');
|
||
const [email, setEmail] = useState('');
|
||
const [website, setWebsite] = useState('');
|
||
const [portfolio, setPortfolio] = useState('');
|
||
const [social, setSocial] = useState('');
|
||
const [noffer, setNoffer] = useState('');
|
||
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||
const [members, setMembers] = useState<string[]>([]);
|
||
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||
const [extras, setExtras] = useState<Record<string, string>>({});
|
||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||
|
||
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 `Anonymous ${noun}`;
|
||
};
|
||
|
||
const toggle = (key: string) =>
|
||
setSelected((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key]));
|
||
|
||
const getCurrentLocation = () => {
|
||
if (!navigator.geolocation) {
|
||
toast({ title: 'Geolocation not supported', 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: 'Location found' });
|
||
},
|
||
(error) => {
|
||
setIsGettingLocation(false);
|
||
toast({ title: 'Location error', 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 tags = await uploadFile(files[0]);
|
||
const url = tags[0]?.[1];
|
||
if (url) {
|
||
setImage(url);
|
||
toast({ title: 'Image uploaded' });
|
||
}
|
||
} catch (error) {
|
||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||
}
|
||
e.target.value = '';
|
||
};
|
||
|
||
const requiredSections = skillSections.filter((s) => s.required);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
// Enforce required skill sections
|
||
for (const section of requiredSections) {
|
||
const keys = section.entries.map(([k]) => k);
|
||
if (!selected.some((s) => keys.includes(s))) {
|
||
toast({ title: `${section.title} required`, description: `Select at least one option under "${section.title}".`, variant: 'destructive' });
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const displayName = getDisplayName();
|
||
const tags: string[][] = [
|
||
['d', identifier],
|
||
['title', displayName],
|
||
['t', selfTag],
|
||
['alt', `Skate ${noun}: ${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)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
||
}
|
||
|
||
for (const s of selected) tags.push(['t', s]);
|
||
|
||
if (image) tags.push(['image', image]);
|
||
if (experience.trim()) tags.push(['experience', experience.trim()]);
|
||
if (showRate && rate.trim()) tags.push(['rate', rate.trim()]);
|
||
if (phone.trim()) tags.push(['phone', phone.trim()]);
|
||
if (email.trim()) tags.push(['email', email.trim()]);
|
||
if (website.trim()) tags.push(['website', website.trim()]);
|
||
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
|
||
if (social.trim()) tags.push(['social', social.trim()]);
|
||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||
|
||
// Craft-specific extras
|
||
for (const f of extraFields) {
|
||
const v = (extras[f.tag] ?? '').trim();
|
||
if (f.type === 'toggle') {
|
||
if (extras[f.tag] === 'true') tags.push([f.tag, 'true']);
|
||
} else if (v) {
|
||
if (f.type === 'url' && !/^https?:\/\//i.test(v)) continue;
|
||
tags.push([f.tag, v]);
|
||
}
|
||
}
|
||
|
||
tags.push(['entity', entityType]);
|
||
if (allowCollective && entityType === 'collective') {
|
||
for (const pk of members) tags.push(['p', pk]);
|
||
if (crewRef) tags.push(['a', crewRef]);
|
||
}
|
||
|
||
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
|
||
|
||
await createEvent({ kind, content: bio.trim(), tags });
|
||
toast({ title: `${heading.replace(/^Become an?/, '').trim()} profile created!`, description: 'Your profile has been published.' });
|
||
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
||
} catch (error) {
|
||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
if (!user) {
|
||
return (
|
||
<Layout>
|
||
<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">
|
||
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
|
||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||
<p className="text-gray-400 mb-6">You need to log in to create a {noun} profile.</p>
|
||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
const hasNip05 = metadata?.nip05;
|
||
const entityCoord = `${kind}:${user.pubkey}:${identifier}`;
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||
{/* Header */}
|
||
<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" />
|
||
Back to The Lab
|
||
</Link>
|
||
<div className="flex items-center gap-3">
|
||
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center', accentChip)}>
|
||
<Icon className="h-6 w-6" />
|
||
</div>
|
||
<div>
|
||
<h1 className={cn('text-2xl sm:text-3xl font-black uppercase leading-none', accentText)} style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||
{heading}
|
||
</h1>
|
||
<p className="text-gray-400 mt-1 text-sm">{tagline}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{!hasNip05 && (
|
||
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="text-yellow-500">⚠️</div>
|
||
<div>
|
||
<h3 className="font-bold text-yellow-400 mb-1">Add NIP-05 to build trust</h3>
|
||
<p className="text-sm text-gray-300 mb-2">
|
||
A NIP-05 identifier helps skaters verify you're the real deal. Highly recommended before listing.
|
||
</p>
|
||
<Link to="/settings">
|
||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||
Add NIP-05 in Settings
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{/* Image + Bio */}
|
||
<Card className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5 space-y-4">
|
||
<div className="flex items-center gap-4">
|
||
<div className={cn('relative w-20 h-20 rounded-2xl border-2 flex items-center justify-center overflow-hidden flex-shrink-0', image ? 'border-white/20' : 'border-dashed border-white/20')}>
|
||
{image ? (
|
||
<>
|
||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||
<button type="button" onClick={() => setImage(null)} className="absolute top-0.5 right-0.5 bg-black/70 rounded-full p-0.5">
|
||
<X className="h-3 w-3 text-white" />
|
||
</button>
|
||
</>
|
||
) : (
|
||
<Icon className="h-7 w-7 text-gray-600" />
|
||
)}
|
||
</div>
|
||
<div className="flex-1">
|
||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Profile image</Label>
|
||
<label className={cn('mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 cursor-pointer text-sm w-fit transition-colors', isUploading && 'opacity-50 pointer-events-none')}>
|
||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||
{isUploading ? 'Uploading…' : 'Upload'}
|
||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">About</Label>
|
||
<Textarea
|
||
id="bio"
|
||
value={bio}
|
||
onChange={(e) => setBio(e.target.value)}
|
||
placeholder={`Tell skaters about your ${noun} work…`}
|
||
className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
|
||
maxLength={3000}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Skill sections */}
|
||
{skillSections.map((section) => (
|
||
<Card key={section.title} className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5">
|
||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
||
{section.title}
|
||
{section.required && <span className={cn('text-[10px]', accentText)}>• required</span>}
|
||
</Label>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{section.entries.map(([key, label]) => {
|
||
const active = selected.includes(key);
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => toggle(key)}
|
||
className={cn(
|
||
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
|
||
active ? accentChip : cn('bg-white/5 border-white/10 text-gray-400', accentChipHover)
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
|
||
{/* Craft-specific extras */}
|
||
{extraFields.length > 0 && (
|
||
<Card className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5 space-y-4">
|
||
{extraFields.map((f) => (
|
||
f.type === 'toggle' ? (
|
||
<div key={f.tag} className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<Label className="text-sm font-bold text-gray-200">{f.label}</Label>
|
||
{f.helper && <p className="text-xs text-gray-500">{f.helper}</p>}
|
||
</div>
|
||
<Switch
|
||
checked={extras[f.tag] === 'true'}
|
||
onCheckedChange={(v) => setExtras((prev) => ({ ...prev, [f.tag]: v ? 'true' : 'false' }))}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div key={f.tag}>
|
||
<Label htmlFor={f.tag} className="text-xs font-bold uppercase tracking-widest text-gray-400">{f.label}</Label>
|
||
<Input
|
||
id={f.tag}
|
||
value={extras[f.tag] ?? ''}
|
||
onChange={(e) => setExtras((prev) => ({ ...prev, [f.tag]: e.target.value }))}
|
||
placeholder={f.placeholder}
|
||
inputMode={f.type === 'url' ? 'url' : undefined}
|
||
className="mt-1.5 bg-white/5 border-white/10"
|
||
/>
|
||
{f.helper && <p className="text-xs text-gray-500 mt-1">{f.helper}</p>}
|
||
</div>
|
||
)
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Location + experience + rate */}
|
||
<Card className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5 space-y-4">
|
||
<div>
|
||
<Label htmlFor="location" className="text-xs font-bold uppercase tracking-widest text-gray-400">Location</Label>
|
||
<div className="mt-1.5 flex gap-2">
|
||
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="City, Country" className="bg-white/5 border-white/10" />
|
||
<Button type="button" variant="outline" onClick={getCurrentLocation} disabled={isGettingLocation} className="border-white/10 flex-shrink-0">
|
||
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
|
||
</Button>
|
||
</div>
|
||
{latitude && longitude && <p className="text-xs text-green-400 mt-1">📍 Pinned to map ({latitude}, {longitude})</p>}
|
||
</div>
|
||
<div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}>
|
||
<div>
|
||
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">Experience (years)</Label>
|
||
<Input id="exp" value={experience} onChange={(e) => setExperience(e.target.value)} placeholder="e.g. 5" inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" />
|
||
</div>
|
||
{showRate && (
|
||
<div>
|
||
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabel}</Label>
|
||
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="e.g. 20k sats / by project" className="mt-1.5 bg-white/5 border-white/10" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Contact + links */}
|
||
<Card className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5 space-y-4">
|
||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Contact & Links</Label>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Phone (optional)" className="bg-white/5 border-white/10" />
|
||
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional)" type="email" className="bg-white/5 border-white/10" />
|
||
<Input value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="Website" inputMode="url" className="bg-white/5 border-white/10" />
|
||
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" />
|
||
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Payments — Lightning address (from profile) + optional CLINK code */}
|
||
<Card className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5 space-y-4">
|
||
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
||
Getting Paid
|
||
</Label>
|
||
<PaymentReadinessNote />
|
||
<NofferField value={noffer} onChange={setNoffer} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Entity type + crew link (collective-capable crafts) */}
|
||
{allowCollective && (
|
||
<Card className="bg-black/60 border-white/10">
|
||
<CardContent className="p-5 space-y-4">
|
||
<EntityTypeField
|
||
entityType={entityType}
|
||
onEntityTypeChange={setEntityType}
|
||
members={members}
|
||
onMembersChange={setMembers}
|
||
accentBg={accentBg}
|
||
accentText={accentText}
|
||
/>
|
||
{entityType === 'collective' && (
|
||
<CrewLinkField value={crewRef} onChange={setCrewRef} accentText={accentText} accentBg={accentBg} />
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Pro upgrade (optional) */}
|
||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText={accentText} entityCoord={entityCoord} />
|
||
|
||
{/* Submit */}
|
||
<Button
|
||
type="submit"
|
||
disabled={isPending}
|
||
className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
|
||
>
|
||
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Publish Profile'}
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|