1725 lines
77 KiB
TypeScript
1725 lines
77 KiB
TypeScript
import { useState, useEffect, type ReactNode } from 'react';
|
|
import { nip19 } from 'nostr-tools';
|
|
import ngeohash from 'ngeohash';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
|
|
import { SpotPicker } from '@/components/SpotPicker';
|
|
import { LabLinkField } from '@/components/LabLinkField';
|
|
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
|
|
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
|
|
import { NofferField } from '@/components/NofferField';
|
|
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
|
|
import { normalizeClinkCode } from '@/lib/clink';
|
|
import { CREW_VIBE_KEYS, CREW_FOCUS_KEYS, CREW_FOCUS_DESC_KEYS, CREW_LOOKING_FOR_KEYS, CREW_ROLE_KEYS } from '@/lib/labelKeys';
|
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
|
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
|
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
|
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR } from '@/lib/coordinator';
|
|
import { useRelays } from '@/lib/relays';
|
|
import { cn } from '@/lib/utils';
|
|
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
|
|
|
|
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
|
|
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
|
mixed: Sparkles,
|
|
builder: HardHat,
|
|
photo: Camera,
|
|
coaching: GraduationCap,
|
|
art: Palette,
|
|
music: Music,
|
|
wellness: Dumbbell,
|
|
};
|
|
|
|
const VIBE_ENTRIES = Object.keys(CREW_VIBE_LABELS);
|
|
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
|
|
/** Number of steps in the create wizard (0-indexed last step). Keep in sync with `steps`. */
|
|
const WIZARD_LAST_STEP = 3;
|
|
|
|
function MemberRow({
|
|
pubkey,
|
|
isAdmin,
|
|
roles,
|
|
onToggleAdmin,
|
|
onToggleRole,
|
|
onRemove,
|
|
}: {
|
|
pubkey: string;
|
|
isAdmin: boolean;
|
|
roles: CrewRole[];
|
|
onToggleAdmin: () => void;
|
|
onToggleRole: (role: CrewRole) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const name = metadata?.name ?? genUserName(pubkey);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
return (
|
|
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={metadata?.picture} alt={name} />
|
|
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
|
|
{name}
|
|
{isAdmin && (
|
|
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
|
<ShieldCheck className="h-2.5 w-2.5" /> {t('socialUi.inviteCard.admin')}
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn(
|
|
'h-7 shrink-0 gap-1 px-2 text-xs',
|
|
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
|
|
)}
|
|
onClick={onToggleAdmin}
|
|
aria-label={isAdmin ? t('socialUi.form.removeAdminRole') : t('socialUi.form.makeAdmin')}
|
|
>
|
|
<ShieldCheck className="h-3.5 w-3.5" />
|
|
{isAdmin ? t('socialUi.inviteCard.admin') : t('socialUi.form.makeAdmin')}
|
|
</Button>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label={t('socialUi.form.removeMember')}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Skate roles — the *functions* on the team. Additive, no powers. */}
|
|
<div className="flex flex-wrap items-center gap-1 pl-11">
|
|
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">{t('socialUi.form.roles')}</span>
|
|
{CREW_ROLE_ORDER.map((role) => {
|
|
const active = roles.includes(role);
|
|
const meta = CREW_ROLE_META[role];
|
|
return (
|
|
<button
|
|
key={role}
|
|
type="button"
|
|
onClick={() => onToggleRole(role)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-bold transition-all active:scale-95',
|
|
active
|
|
? 'border-teal-500 bg-teal-500/20 text-teal-200'
|
|
: 'border-gray-700 bg-gray-900/60 text-gray-500 hover:border-teal-500/40 hover:text-teal-300',
|
|
)}
|
|
aria-pressed={active}
|
|
>
|
|
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Decode an npub / nprofile / hex string into a hex pubkey, or null. */
|
|
function decodePubkeyInput(raw: string): string | null {
|
|
const val = raw.trim();
|
|
if (!val) return null;
|
|
try {
|
|
if (val.startsWith('npub1') || val.startsWith('nostr:npub1')) {
|
|
const decoded = nip19.decode(val.replace(/^nostr:/, ''));
|
|
if (decoded.type === 'npub') return decoded.data;
|
|
} else if (val.startsWith('nprofile1') || val.startsWith('nostr:nprofile1')) {
|
|
const decoded = nip19.decode(val.replace(/^nostr:/, ''));
|
|
if (decoded.type === 'nprofile') return decoded.data.pubkey;
|
|
} else if (/^[0-9a-f]{64}$/i.test(val)) {
|
|
return val.toLowerCase();
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Live preview of the skater being typed into the member field — resolves the
|
|
* pubkey to an avatar + name so the founder can confirm it's the right person
|
|
* *before* adding. Shows a neutral "resolving…" state while metadata loads.
|
|
*/
|
|
function MemberPreview({ pubkey, alreadyAdded }: { pubkey: string; alreadyAdded: boolean }) {
|
|
const { t } = useTranslation();
|
|
const author = useAuthor(pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const name = metadata?.name ?? genUserName(pubkey);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
return (
|
|
<div className="flex items-center gap-2.5 rounded-lg border border-teal-500/30 bg-teal-500/[0.06] p-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={metadata?.picture} alt={name} />
|
|
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-bold text-white">{name}</p>
|
|
<p className="truncate font-mono text-[11px] text-gray-500">{npub.slice(0, 20)}…</p>
|
|
</div>
|
|
{alreadyAdded ? (
|
|
<span className="shrink-0 rounded-full bg-gray-700 px-2 py-0.5 text-[10px] font-bold uppercase text-gray-300">
|
|
{t('socialUi.form.added')}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-bold text-teal-400">
|
|
<Check className="h-3 w-3" /> {t('socialUi.form.ready')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CrewFormProps {
|
|
/** Existing crew when editing. */
|
|
existing?: SkateCrew | null;
|
|
}
|
|
|
|
/** Static map for coordinator health status → i18n key (no dynamic keys). */
|
|
const HEALTH_LABEL: Record<string, string> = {
|
|
idle: 'socialUi.coordinator.statusIdle',
|
|
checking: 'socialUi.coordinator.statusChecking',
|
|
online: 'socialUi.coordinator.statusOnline',
|
|
stale: 'socialUi.coordinator.statusStale',
|
|
offline: 'socialUi.coordinator.statusOffline',
|
|
error: 'socialUi.coordinator.statusError',
|
|
};
|
|
|
|
/**
|
|
* A collapsible group for the "Extras" step, so optional power-ups don't wall off
|
|
* a new founder. Header is the group label; the blocks inside keep their own.
|
|
*/
|
|
function ExtrasGroup({
|
|
icon: Icon,
|
|
title,
|
|
hint,
|
|
defaultOpen = false,
|
|
children,
|
|
}: {
|
|
icon: typeof Users;
|
|
title: string;
|
|
hint?: string;
|
|
defaultOpen?: boolean;
|
|
children: ReactNode;
|
|
}) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
return (
|
|
<Collapsible open={open} onOpenChange={setOpen} className="rounded-xl border border-white/10 bg-white/[0.02]">
|
|
<CollapsibleTrigger className="flex w-full items-center gap-2 p-3 text-left">
|
|
<Icon className="h-4 w-4 shrink-0 text-teal-400" />
|
|
<span className="flex-1 text-sm font-black uppercase tracking-wide text-gray-200">{title}</span>
|
|
{hint && <span className="hidden text-[10px] text-gray-500 sm:block">{hint}</span>}
|
|
<ChevronDown className={cn('h-4 w-4 shrink-0 text-gray-500 transition-transform', open && 'rotate-180')} />
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-4 px-3 pb-3">{children}</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
export function CrewForm({ existing }: CrewFormProps) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const { toast } = useToast();
|
|
|
|
// Create flow = guided wizard; edit flow = flat single page (power users
|
|
// want everything at once). The wizard staggers the ~15 sections into 4
|
|
// digestible steps so a first-timer can spin up a crew in seconds.
|
|
const isEditing = !!existing;
|
|
const [step, setStep] = useState(0);
|
|
// Track nav direction so step transitions slide in from the correct side.
|
|
const [navDir, setNavDir] = useState<'forward' | 'back'>('forward');
|
|
const goToStep = (target: number) => {
|
|
setNavDir(target >= step ? 'forward' : 'back');
|
|
setStep(target);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
const [name, setName] = useState(existing?.name ?? '');
|
|
const [bio, setBio] = useState(existing?.bio ?? '');
|
|
const [location, setLocation] = useState(existing?.location ?? '');
|
|
const [latitude, setLatitude] = useState('');
|
|
const [longitude, setLongitude] = useState('');
|
|
// Preset vibes (known keys) vs custom vibes (user-defined hashtags).
|
|
// Split the existing crew's vibes into the two buckets on load.
|
|
const presetKeys = new Set(Object.keys(CREW_VIBE_LABELS));
|
|
const [vibes, setVibes] = useState<string[]>(
|
|
(existing?.vibes ?? []).filter((v) => presetKeys.has(v))
|
|
);
|
|
const [customVibes, setCustomVibes] = useState<string[]>(
|
|
(existing?.vibes ?? []).filter((v) => !presetKeys.has(v))
|
|
);
|
|
const [customVibeInput, setCustomVibeInput] = useState('');
|
|
const [focus, setFocus] = useState<CrewFocus>(existing?.focus ?? 'mixed');
|
|
const [agentEnabled, setAgentEnabled] = useState(existing?.agentEnabled ?? false);
|
|
const [founded, setFounded] = useState(existing?.founded ?? '');
|
|
const [image, setImage] = useState<string | null>(existing?.image ?? null);
|
|
const [website, setWebsite] = useState(existing?.website ?? '');
|
|
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
|
|
const [bandName, setBandName] = useState(existing?.bandName ?? '');
|
|
const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? '');
|
|
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
|
|
const [socialInput, setSocialInput] = useState('');
|
|
const [nip05s, setNip05s] = useState<string[]>(existing?.nip05s ?? (existing?.nip05 ? [existing.nip05] : []));
|
|
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
|
|
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
|
|
const [admins, setAdmins] = useState<string[]>(existing?.admins ?? []);
|
|
const [memberInput, setMemberInput] = useState('');
|
|
const [memberError, setMemberError] = useState<string | null>(null);
|
|
|
|
// Skate roles per member (pubkey → set of CrewRole). Seed from the existing crew.
|
|
const [memberRoles, setMemberRoles] = useState<Record<string, CrewRole[]>>(() => {
|
|
const seed: Record<string, CrewRole[]> = {};
|
|
for (const { pubkey, role } of existing?.crewRoles ?? []) {
|
|
(seed[pubkey] ??= []).push(role);
|
|
}
|
|
return seed;
|
|
});
|
|
|
|
const toggleAdmin = (pk: string) =>
|
|
setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk]));
|
|
|
|
const toggleMemberRole = (pk: string, role: CrewRole) =>
|
|
setMemberRoles((prev) => {
|
|
const cur = prev[pk] ?? [];
|
|
const next = cur.includes(role) ? cur.filter((r) => r !== role) : [...cur, role];
|
|
return { ...prev, [pk]: next };
|
|
});
|
|
|
|
const removeMember = (pk: string) => {
|
|
setMembers((prev) => prev.filter((m) => m !== pk));
|
|
setAdmins((prev) => prev.filter((a) => a !== pk));
|
|
setMemberRoles((prev) => {
|
|
const next = { ...prev };
|
|
delete next[pk];
|
|
return next;
|
|
});
|
|
};
|
|
const [selectedSpots, setSelectedSpots] = useState<string[]>(
|
|
(existing?.spotRefs ?? []).map((ref) => {
|
|
const parts = ref.split(':');
|
|
return `${parts[1]}:${parts[2]}`;
|
|
})
|
|
);
|
|
const [labRefs, setLabRefs] = useState<string[]>(existing?.labRefs ?? []);
|
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
|
|
|
// Open call ("looking for") — Lab craft slugs the crew is hiring for.
|
|
const [lookingFor, setLookingFor] = useState<string[]>(existing?.lookingFor ?? []);
|
|
const toggleLookingFor = (slug: string) =>
|
|
setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]));
|
|
|
|
// Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator pointer.
|
|
const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? '');
|
|
const [coordinatorInput, setCoordinatorInput] = useState(
|
|
crewCoordinatorInputValue(existing?.coordinator) ||
|
|
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
|
|
);
|
|
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
|
const { coordinator: myCoordinator } = useCoordinator();
|
|
// Live parse so the founder gets feedback (and the publish path reuses it).
|
|
const [cordnGroupInput, setCordnGroupInput] = useState(existing?.cordnGroup ?? '');
|
|
const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput);
|
|
// On-demand health probe (the "Check" button) for a coordinator addressed by
|
|
// pubkey — mirrors the Settings → Coordinator card.
|
|
const coordinatorHealth = useCoordinatorHealth(
|
|
parsedCoordinator?.pubkey
|
|
? {
|
|
pubkey: parsedCoordinator.pubkey,
|
|
relays: /^wss?:\/\//i.test(parsedCoordinator.url) ? [parsedCoordinator.url] : [],
|
|
addedAt: 0,
|
|
}
|
|
: null,
|
|
);
|
|
// Crew video / montage — YouTube or Instagram URL ("The Part").
|
|
const [videoUrl, setVideoUrl] = useState(existing?.videoUrl ?? '');
|
|
|
|
// Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing.
|
|
const { data: existingGoal } = useCrewGoal(existing?.goalRef);
|
|
const { write } = useRelays();
|
|
const [goalEnabled, setGoalEnabled] = useState(!!existing?.goalRef);
|
|
const [goalTitle, setGoalTitle] = useState('');
|
|
const [goalTarget, setGoalTarget] = useState('');
|
|
const [goalPrefilled, setGoalPrefilled] = useState(false);
|
|
|
|
// When an existing goal loads, prefill the fields once.
|
|
useEffect(() => {
|
|
if (existingGoal && !goalPrefilled) {
|
|
setGoalPrefilled(true);
|
|
setGoalEnabled(true);
|
|
setGoalTitle(existingGoal.description || existingGoal.summary || '');
|
|
setGoalTarget(existingGoal.targetSats ? String(existingGoal.targetSats) : '');
|
|
}
|
|
}, [existingGoal, goalPrefilled]);
|
|
|
|
const toggleVibe = (v: string) =>
|
|
setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
|
|
|
|
// Normalize a free-text vibe into a hashtag-friendly slug:
|
|
// strip leading #, lowercase, collapse whitespace to hyphens, drop invalid chars.
|
|
const normalizeVibe = (raw: string) =>
|
|
raw
|
|
.trim()
|
|
.replace(/^#+/, '')
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9-]/g, '')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
|
|
const addCustomVibe = () => {
|
|
const slug = normalizeVibe(customVibeInput);
|
|
setCustomVibeInput('');
|
|
if (!slug) return;
|
|
// Don't duplicate a preset or an existing custom vibe
|
|
if (presetKeys.has(slug) || customVibes.includes(slug)) return;
|
|
setCustomVibes((prev) => [...prev, slug]);
|
|
};
|
|
|
|
const addSocial = () => {
|
|
const raw = socialInput.trim();
|
|
setSocialInput('');
|
|
if (!raw) return;
|
|
// Coerce a bare domain to https:// so it renders as a link; keep a non-URL
|
|
// (e.g. a @handle) as typed so nothing is silently dropped.
|
|
const safe = sanitizeUrl(normalizeUrlInput(raw)) ?? raw;
|
|
if (socials.includes(safe)) return;
|
|
setSocials((prev) => [...prev, safe]);
|
|
};
|
|
|
|
// Crew NIP-05 handles — up to 5, each verified against the founder's key.
|
|
const founderPubkey = existing?.pubkey ?? user?.pubkey;
|
|
|
|
// Live-decode whatever's typed in the member field for the preview chip.
|
|
const memberPreviewPk = decodePubkeyInput(memberInput);
|
|
const memberInputDirty = memberInput.trim().length > 0;
|
|
|
|
const addMember = () => {
|
|
const raw = memberInput.trim();
|
|
if (!raw) return;
|
|
const hex = decodePubkeyInput(raw);
|
|
if (!hex) {
|
|
setMemberError(t('socialUi.form.invalidMember'));
|
|
return;
|
|
}
|
|
if (members.includes(hex)) {
|
|
setMemberError(t('socialUi.form.memberAlreadyAdded'));
|
|
return;
|
|
}
|
|
setMembers([...members, hex]);
|
|
setMemberInput('');
|
|
setMemberError(null);
|
|
};
|
|
|
|
const getCurrentLocation = () => {
|
|
if (!navigator.geolocation) {
|
|
toast({ title: t('socialUi.form.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('socialUi.form.locationFound') });
|
|
},
|
|
(error) => {
|
|
setIsGettingLocation(false);
|
|
toast({ title: t('socialUi.form.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 tags = await uploadFile(files[0]);
|
|
const url = tags[0]?.[1];
|
|
if (url) {
|
|
setImage(url);
|
|
toast({ title: t('socialUi.form.logoUploaded') });
|
|
}
|
|
} catch (error) {
|
|
toast({ title: t('socialUi.form.uploadFailed'), description: error instanceof Error ? error.message : t('socialUi.form.failed'), variant: 'destructive' });
|
|
}
|
|
e.target.value = '';
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// In the create wizard, Enter/submit before the final step should just
|
|
// advance rather than publish a half-filled crew. (Edit mode has no steps.)
|
|
if (!existing && step < WIZARD_LAST_STEP) {
|
|
if (step === 0 && !name.trim()) {
|
|
toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
setNavDir('forward');
|
|
setStep((s) => Math.min(s + 1, WIZARD_LAST_STEP));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
return;
|
|
}
|
|
|
|
if (!name.trim()) {
|
|
toast({ title: t('socialUi.form.nameRequired'), description: t('socialUi.form.nameRequiredDesc'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
// An invalid coordinator would be silently dropped on save — tell the user.
|
|
if (coordinatorInput.trim() && !parsedCoordinator) {
|
|
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
|
|
|
// ── Crew treasury: publish/refresh a NIP-75 zap goal, then link it. ──
|
|
// The goal is non-custodial — zaps settle to the founder's own Lightning
|
|
// wallet. We only publish the public goal + tally later.
|
|
let goalRef: string | undefined = existing?.goalRef;
|
|
const targetSats = parseInt(goalTarget, 10);
|
|
if (goalEnabled && goalTitle.trim() && targetSats > 0) {
|
|
const relayUrls = write;
|
|
const needsNewGoal =
|
|
!existingGoal ||
|
|
existingGoal.description !== goalTitle.trim() ||
|
|
existingGoal.targetSats !== targetSats;
|
|
if (needsNewGoal) {
|
|
const goalEvent = await createEvent({
|
|
kind: ZAP_GOAL_KIND,
|
|
content: goalTitle.trim(),
|
|
tags: [
|
|
['amount', String(targetSats * 1000)], // millisats
|
|
['relays', ...(relayUrls.length ? relayUrls : ['wss://relay.nostr.band'])],
|
|
['summary', `${name.trim()} crew treasury`],
|
|
['alt', `Crew funding goal: ${goalTitle.trim()}`],
|
|
],
|
|
});
|
|
goalRef = goalEvent.id;
|
|
}
|
|
} else if (!goalEnabled) {
|
|
goalRef = undefined; // turning the treasury off unlinks the goal
|
|
}
|
|
|
|
const tags: string[][] = [
|
|
['d', identifier],
|
|
['title', name.trim()],
|
|
['t', 'skatecrew'],
|
|
['alt', `Skate crew: ${name.trim()}`],
|
|
];
|
|
|
|
if (location.trim()) tags.push(['location', location.trim()]);
|
|
|
|
// Home turf geohash — prefer freshly captured coords, else keep existing
|
|
if (latitude && longitude) {
|
|
const lat = parseFloat(latitude);
|
|
const lng = parseFloat(longitude);
|
|
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
|
} else if (existing?.geohash) {
|
|
tags.push(['g', existing.geohash]);
|
|
}
|
|
|
|
for (const v of vibes) tags.push(['t', v]);
|
|
for (const v of customVibes) tags.push(['t', v]);
|
|
// Lab scene focus — only tagged when not the default "mixed" crew.
|
|
if (focus !== 'mixed') tags.push(['t', CREW_FOCUS_TAG[focus]]);
|
|
// Crew AI agent — queryable marker tag when enabled.
|
|
if (agentEnabled) tags.push(['t', 'crew-agent']);
|
|
if (founded.trim()) {
|
|
const year = parseInt(founded, 10);
|
|
const maxYear = new Date().getFullYear();
|
|
if (Number.isFinite(year)) {
|
|
tags.push(['founded', String(Math.min(maxYear, Math.max(1954, year)))]);
|
|
}
|
|
}
|
|
if (image) tags.push(['image', image]);
|
|
{
|
|
const safe = website.trim() ? sanitizeUrl(normalizeUrlInput(website)) : null;
|
|
if (website.trim()) tags.push(['website', safe ?? website.trim()]);
|
|
}
|
|
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
|
if (bandName.trim()) tags.push(['band', bandName.trim()]);
|
|
{
|
|
const safe = bandUrl.trim() ? sanitizeUrl(normalizeUrlInput(bandUrl)) : null;
|
|
if (bandUrl.trim()) tags.push(['band-url', safe ?? bandUrl.trim()]);
|
|
}
|
|
for (const s of socials) {
|
|
if (s.trim()) tags.push(['social', s.trim()]);
|
|
}
|
|
for (const handle of nip05s) tags.push(['nip05', handle]);
|
|
if (isValidNoffer(noffer.trim())) tags.push(['noffer', normalizeClinkCode(noffer)]);
|
|
|
|
// Members: admins get an `admin` role marker on their `p` tag.
|
|
for (const pk of members) {
|
|
if (admins.includes(pk)) {
|
|
tags.push(['p', pk, '', 'admin']);
|
|
} else {
|
|
tags.push(['p', pk]);
|
|
}
|
|
}
|
|
|
|
for (const spotId of selectedSpots) {
|
|
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
|
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
|
|
}
|
|
|
|
// Linked Lab profiles (cross-link) — a-tags pointing at Lab kinds
|
|
for (const coord of labRefs) {
|
|
tags.push(['a', coord]);
|
|
}
|
|
|
|
// Crew treasury goal (NIP-75) linkage.
|
|
if (goalRef) tags.push(['goal', goalRef]);
|
|
|
|
// Open call — Lab crafts the crew is looking for.
|
|
for (const slug of lookingFor) {
|
|
tags.push(['looking-for', slug]);
|
|
}
|
|
|
|
// Sponsoring shop — must be a valid NIP-15 stall coordinate (30017:pubkey:d).
|
|
const sponsorCoord = parseAddrCoord(sponsorRef.trim());
|
|
if (sponsorCoord && sponsorCoord.kind === SKATE_SHOP_KIND) {
|
|
tags.push(['sponsor', sponsorCoord.coord]);
|
|
}
|
|
|
|
// Cordn / MLS coordinator for the crew's encrypted chat. Accepts the same
|
|
// pointer formats as Settings → Coordinator (nprofile / cordn.net link /
|
|
// npub / hex) as well as a plain endpoint URL, so a coordinator run from
|
|
// Cordn's phone app works too. Tag: ['coordinator', url, pubkey?].
|
|
if (parsedCoordinator) {
|
|
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
|
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
|
tags.push(coordinatorTag);
|
|
}
|
|
|
|
// Cordn group link for the crew's MLS chat (created in Staircase / Cordn web).
|
|
if (cordnGroupInput.trim()) {
|
|
tags.push(['cordn', cordnGroupInput.trim()]);
|
|
}
|
|
|
|
// Crew video / montage — only stored if it's a valid YouTube/Instagram URL.
|
|
const vid = videoUrl.trim();
|
|
if (vid && isCrewVideoUrl(vid)) {
|
|
tags.push(['video', vid]);
|
|
}
|
|
|
|
// Skate roles (filmer / photographer / …) — only for the founder or a
|
|
// listed member, so roles can't dangle on non-roster pubkeys.
|
|
const roleablePubkeys = new Set([user!.pubkey.toLowerCase(), ...members.map((m) => m.toLowerCase())]);
|
|
for (const [pk, roles] of Object.entries(memberRoles)) {
|
|
if (!roleablePubkeys.has(pk.toLowerCase())) continue;
|
|
for (const role of roles) {
|
|
tags.push(['crew-role', pk.toLowerCase(), role]);
|
|
}
|
|
}
|
|
|
|
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
|
|
|
toast({ title: existing ? t('socialUi.form.crewUpdated') : t('socialUi.form.crewCreated'), description: t('socialUi.form.publishedToScene') });
|
|
navigate(`/crew/${user?.pubkey}/${identifier}`);
|
|
} catch (error) {
|
|
toast({ title: t('socialUi.form.publishFailed'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
// ─── Section blocks ─────────────────────────────────────────────────────
|
|
// Each visible field group is a self-contained element that closes over the
|
|
// component's state. They're composed into wizard steps (create) or one flat
|
|
// scroll (edit) below — no field or publish logic changes between modes.
|
|
|
|
const logoBlock = (
|
|
<div className="space-y-2">
|
|
<Label>{t('socialUi.form.logoLabel')}</Label>
|
|
{image ? (
|
|
<div className="relative w-32 h-32 rounded-2xl overflow-hidden bg-gray-800">
|
|
<img src={image} alt={t('socialUi.form.logoAlt')} 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="w-32 h-32 rounded-2xl border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50 hover:border-teal-500/50 transition-colors">
|
|
<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('socialUi.form.upload')}</span></>}
|
|
</label>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const nameValid = name.trim().length >= 2;
|
|
const nameBlock = (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="crew-name">{t('socialUi.form.crewName')} <span className="text-teal-400">*</span></Label>
|
|
{nameValid && (
|
|
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-teal-400">
|
|
<Check className="h-3 w-3" /> {t('socialUi.form.looksGood')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id="crew-name"
|
|
placeholder={t('socialUi.form.namePlaceholder')}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
maxLength={100}
|
|
className={cn(
|
|
'bg-gray-800 border-gray-700 text-base pr-10 transition-colors',
|
|
nameValid && 'border-teal-500/50 focus-visible:ring-teal-500/40',
|
|
)}
|
|
autoFocus={!isEditing}
|
|
/>
|
|
{nameValid && (
|
|
<Check className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-teal-400" />
|
|
)}
|
|
</div>
|
|
<p className="text-[11px] text-gray-500">{t('socialUi.form.nameHint')}</p>
|
|
</div>
|
|
);
|
|
|
|
const bioBlock = (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="crew-bio">{t('socialUi.form.bioLabel')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span></Label>
|
|
{bio.length > 0 && (
|
|
<span className={cn('text-[11px] tabular-nums', bio.length > 2800 ? 'text-amber-400' : 'text-gray-500')}>
|
|
{bio.length}/3000
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Textarea id="crew-bio" placeholder={t('socialUi.form.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
);
|
|
|
|
const locationBlock = (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="crew-location">{t('socialUi.form.homeTurf')}</Label>
|
|
<Input id="crew-location" placeholder={t('socialUi.form.homeTurfPlaceholder')} value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="crew-founded">{t('socialUi.form.founded')}</Label>
|
|
<Input id="crew-founded" type="number" min={1954} max={new Date().getFullYear()} placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('socialUi.form.mapPin')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span></Label>
|
|
<p className="text-xs text-gray-500">{t('socialUi.form.mapPinHint')}</p>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input placeholder={t('socialUi.form.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
<Input placeholder={t('socialUi.form.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('socialUi.form.gettingLocation')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('socialUi.form.useCurrentLocation')}</>}
|
|
</Button>
|
|
{existing?.geohash && !latitude && (
|
|
<p className="text-xs text-gray-500">{t('socialUi.form.leaveBlankLocation')}</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const vibeBlock = (
|
|
<>
|
|
{/* Vibe tags */}
|
|
<div className="space-y-3">
|
|
<Label>{t('socialUi.form.vibeLabel')}</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{VIBE_ENTRIES.map((key) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => toggleVibe(key)}
|
|
className={cn(
|
|
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
|
|
vibes.includes(key)
|
|
? 'bg-teal-500 text-black border-teal-500'
|
|
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
|
|
)}
|
|
>
|
|
{CREW_VIBE_KEYS[key] ? t(CREW_VIBE_KEYS[key]) : key}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Custom vibe / focus (user-defined hashtags) */}
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-gray-500">{t('socialUi.form.customVibeHint')}</p>
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">#</span>
|
|
<Input
|
|
placeholder={t('socialUi.form.customVibePlaceholder')}
|
|
value={customVibeInput}
|
|
onChange={(e) => setCustomVibeInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomVibe(); } }}
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
className="bg-gray-800 border-gray-700 pl-7"
|
|
/>
|
|
</div>
|
|
<Button type="button" onClick={addCustomVibe} className={cn('shrink-0 gap-1', TEAL)}>
|
|
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
|
|
</Button>
|
|
</div>
|
|
{customVibes.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{customVibes.map((v) => (
|
|
<span
|
|
key={v}
|
|
className="inline-flex items-center gap-1 rounded-xl border border-teal-500/60 bg-teal-500/15 px-3 py-1.5 text-xs font-bold text-teal-300"
|
|
>
|
|
#{v}
|
|
<button
|
|
type="button"
|
|
onClick={() => setCustomVibes(customVibes.filter((x) => x !== v))}
|
|
className="text-teal-400/70 hover:text-red-400"
|
|
aria-label={t('socialUi.form.removeVibe', { vibe: v })}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const crewTypeBlock = (
|
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
|
<div>
|
|
<Label className="flex items-center gap-2 text-teal-400">
|
|
<Sparkles className="h-4 w-4" /> {t('socialUi.form.crewType')}
|
|
</Label>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{t('socialUi.form.crewTypeDescA')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.theLab')}</strong>{t('socialUi.form.crewTypeDescB')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.mixedWord')}</strong>{t('socialUi.form.crewTypeDescC')}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{FOCUS_ORDER.map((key) => {
|
|
const Icon = FOCUS_ICONS[key];
|
|
const selected = focus === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setFocus(key)}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
'flex items-start gap-2.5 rounded-xl border p-3 text-left transition-all active:scale-[0.98]',
|
|
selected
|
|
? 'border-teal-500 bg-teal-500/15 ring-1 ring-teal-500/40'
|
|
: 'border-gray-700 bg-gray-800 hover:bg-gray-700/70'
|
|
)}
|
|
>
|
|
<Icon className={cn('mt-0.5 h-4 w-4 flex-shrink-0', selected ? 'text-teal-300' : 'text-gray-400')} />
|
|
<span className="min-w-0">
|
|
<span className={cn('block text-sm font-bold leading-tight', selected ? 'text-teal-200' : 'text-gray-200')}>
|
|
{CREW_FOCUS_KEYS[key] ? t(CREW_FOCUS_KEYS[key]) : CREW_FOCUS_LABELS[key]}
|
|
</span>
|
|
<span className="mt-0.5 block text-[11px] leading-snug text-gray-500">
|
|
{CREW_FOCUS_DESC_KEYS[key] ? t(CREW_FOCUS_DESC_KEYS[key]) : CREW_FOCUS_DESCRIPTIONS[key]}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const agentBlock = (
|
|
<div className="space-y-3 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<Label className="flex items-center gap-2 text-teal-400">
|
|
<img
|
|
src={CREW_AGENT_AVATAR}
|
|
alt="Sasquatch"
|
|
className="h-5 w-5 rounded-md object-cover"
|
|
loading="lazy"
|
|
/>
|
|
{t('socialUi.form.sasquatch')}
|
|
<span className="rounded-full bg-[#ccff00]/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-[#ccff00]">{t('socialUi.form.pro')}</span>
|
|
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">{t('socialUi.form.beta')}</span>
|
|
</Label>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{t('socialUi.form.agentDescA')} <strong className="text-gray-300">{t('socialUi.form.sasquatch')}</strong>{t('socialUi.form.agentDescB')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.agentDescStrong')}</strong>{t('socialUi.form.agentDescC')}
|
|
</p>
|
|
<p className="mt-1 text-[11px] leading-snug text-gray-500">{t('socialUi.form.agentProNote')}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={agentEnabled}
|
|
aria-label={t('socialUi.form.enableAgent')}
|
|
onClick={() => setAgentEnabled((v) => !v)}
|
|
className={cn(
|
|
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
|
|
agentEnabled ? 'bg-teal-500' : 'bg-gray-700'
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
|
|
agentEnabled ? 'translate-x-[22px]' : 'translate-x-0.5'
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{agentEnabled && (
|
|
<p className="flex items-center gap-1.5 text-[11px] font-bold text-teal-400 animate-in fade-in duration-200">
|
|
<Check className="h-3.5 w-3.5" /> {t('socialUi.form.agentEnabledNote')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const openCallBlock = (
|
|
<div className="space-y-3 rounded-lg border border-fuchsia-500/30 bg-fuchsia-500/[0.04] p-4">
|
|
<div>
|
|
<Label className="flex items-center gap-2 text-fuchsia-400">
|
|
<Megaphone className="h-4 w-4" /> {t('socialUi.form.openCall')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
|
</Label>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{t('socialUi.form.openCallDescA')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.theLab')}</strong>{t('socialUi.form.openCallDescB')}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{CREW_LOOKING_FOR_OPTIONS.map((opt) => {
|
|
const on = lookingFor.includes(opt.slug);
|
|
return (
|
|
<button
|
|
key={opt.slug}
|
|
type="button"
|
|
onClick={() => toggleLookingFor(opt.slug)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-bold transition-all active:scale-95',
|
|
on
|
|
? 'border-fuchsia-500 bg-fuchsia-500/20 text-fuchsia-200'
|
|
: 'border-gray-700 bg-gray-800 text-gray-300 hover:border-fuchsia-500/40',
|
|
)}
|
|
>
|
|
<span>{opt.emoji}</span>
|
|
{CREW_LOOKING_FOR_KEYS[opt.slug] ? t(CREW_LOOKING_FOR_KEYS[opt.slug]) : opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const treasuryBlock = SHOW_CREW_TREASURY ? (
|
|
<div className="space-y-3 rounded-lg border border-orange-500/30 bg-orange-500/[0.04] p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<Label className="flex items-center gap-2 text-orange-400">
|
|
<Target className="h-4 w-4" /> {t('socialUi.treasury.title')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
|
</Label>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{t('socialUi.form.treasuryDescA')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.treasuryDescStrong')}</strong>{' '}
|
|
{t('socialUi.form.treasuryDescB')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={goalEnabled}
|
|
aria-label={t('socialUi.form.enableTreasury')}
|
|
onClick={() => setGoalEnabled((v) => !v)}
|
|
className={cn(
|
|
'relative mt-1 h-6 w-11 flex-shrink-0 rounded-full transition-colors',
|
|
goalEnabled ? 'bg-orange-500' : 'bg-gray-700',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform',
|
|
goalEnabled ? 'translate-x-[22px]' : 'translate-x-0.5',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{goalEnabled && (
|
|
<div className="space-y-3 pt-1">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="goal-title" className="text-xs text-gray-400">{t('socialUi.form.goalWhat')}</Label>
|
|
<Input
|
|
id="goal-title"
|
|
placeholder={t('socialUi.form.goalPlaceholder')}
|
|
value={goalTitle}
|
|
onChange={(e) => setGoalTitle(e.target.value)}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="goal-target" className="text-xs text-gray-400">{t('socialUi.form.goalTarget')}</Label>
|
|
<Input
|
|
id="goal-target"
|
|
type="number"
|
|
min={1}
|
|
placeholder="500000"
|
|
value={goalTarget}
|
|
onChange={(e) => setGoalTarget(e.target.value)}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
</div>
|
|
<p className="flex items-start gap-1.5 text-[11px] text-gray-500">
|
|
<Target className="mt-0.5 h-3 w-3 shrink-0 text-orange-400" />
|
|
{t('socialUi.form.goalHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
const membersBlock = (
|
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
|
<div>
|
|
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> {t('socialUi.form.members')}</Label>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
{t('socialUi.form.membersDesc')}
|
|
</p>
|
|
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
|
|
<ShieldCheck className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
|
|
<span>
|
|
{t('socialUi.form.adminsDescA')} <strong className="text-teal-300">{t('socialUi.form.adminsWord')}</strong> {t('socialUi.form.adminsDescB')}
|
|
</span>
|
|
</p>
|
|
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
|
|
<Eye className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
|
|
<span>
|
|
{t('socialUi.form.visibilityDescA')} <strong className="text-teal-300">{t('socialUi.form.ownVisibility')}</strong> {t('socialUi.form.visibilityDescB')}{' '}
|
|
<em>{t('socialUi.form.publicWord')}</em> {t('common.or')} <em>{t('socialUi.form.crewOnlyWord')}</em> {t('socialUi.form.visibilityDescC')}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Founder's own skate roles (you're the leader — are you also the filmer?) */}
|
|
{user && (
|
|
<div className="rounded-lg border border-teal-500/20 bg-teal-500/[0.04] p-2.5">
|
|
<p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider text-teal-400">
|
|
<ShieldCheck className="h-3 w-3" /> {t('socialUi.form.yourRoles')}
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{CREW_ROLE_ORDER.map((role) => {
|
|
const active = (memberRoles[user.pubkey.toLowerCase()] ?? []).includes(role);
|
|
const meta = CREW_ROLE_META[role];
|
|
return (
|
|
<button
|
|
key={role}
|
|
type="button"
|
|
onClick={() => toggleMemberRole(user.pubkey.toLowerCase(), role)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-bold transition-all active:scale-95',
|
|
active
|
|
? 'border-teal-500 bg-teal-500/20 text-teal-200'
|
|
: 'border-gray-700 bg-gray-900/60 text-gray-500 hover:border-teal-500/40 hover:text-teal-300',
|
|
)}
|
|
aria-pressed={active}
|
|
>
|
|
<span className="leading-none">{meta.emoji}</span> {CREW_ROLE_KEYS[role] ? t(CREW_ROLE_KEYS[role]) : meta.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="npub1… / nprofile1… / hex pubkey"
|
|
value={memberInput}
|
|
onChange={(e) => { setMemberInput(e.target.value); setMemberError(null); }}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addMember(); } }}
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
className={cn(
|
|
'bg-gray-800 border-gray-700 font-mono text-sm',
|
|
memberPreviewPk && 'border-teal-500/50',
|
|
memberInputDirty && !memberPreviewPk && 'border-amber-500/40',
|
|
)}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={addMember}
|
|
disabled={!memberPreviewPk || members.includes(memberPreviewPk)}
|
|
className={cn('shrink-0 gap-1', TEAL)}
|
|
>
|
|
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Live preview of the resolved skater (or a gentle hint if it's not a key yet). */}
|
|
{memberPreviewPk ? (
|
|
<MemberPreview pubkey={memberPreviewPk} alreadyAdded={members.includes(memberPreviewPk)} />
|
|
) : memberInputDirty ? (
|
|
<p className="text-xs text-amber-400/90">
|
|
{t('socialUi.form.notAKeyA')} <span className="font-mono">npub1…</span>{t('socialUi.form.notAKeyB')} <span className="font-mono">nprofile1…</span>{t('socialUi.form.notAKeyC')}
|
|
</p>
|
|
) : null}
|
|
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
|
|
{members.length > 0 && (
|
|
<div className="space-y-2">
|
|
{members.map((pk) => (
|
|
<MemberRow
|
|
key={pk}
|
|
pubkey={pk}
|
|
isAdmin={admins.includes(pk)}
|
|
roles={memberRoles[pk] ?? []}
|
|
onToggleAdmin={() => toggleAdmin(pk)}
|
|
onToggleRole={(role) => toggleMemberRole(pk, role)}
|
|
onRemove={() => removeMember(pk)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const spotsBlock = (
|
|
<SpotPicker
|
|
selectedSpots={selectedSpots}
|
|
onSpotsChange={setSelectedSpots}
|
|
maxSpots={10}
|
|
label={t('socialUi.form.homeSpots')}
|
|
description={t('socialUi.form.homeSpotsDesc')}
|
|
/>
|
|
);
|
|
|
|
const labLinkBlock = <LabLinkField value={labRefs} onChange={setLabRefs} />;
|
|
|
|
const videoBlock = (
|
|
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/[0.04] p-4">
|
|
<Label htmlFor="crew-video" className="flex items-center gap-2 text-teal-400">
|
|
<Film className="h-4 w-4" /> {t('socialUi.video.title')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
|
</Label>
|
|
<p className="text-xs text-gray-500">
|
|
{t('socialUi.form.videoDescA')}{' '}
|
|
<span className="font-semibold text-teal-300">YouTube</span> {t('common.or')}{' '}
|
|
<span className="font-semibold text-teal-300">Instagram</span>{t('socialUi.form.videoDescB')}
|
|
</p>
|
|
<Input
|
|
id="crew-video"
|
|
placeholder="https://youtube.com/watch?v=… or https://instagram.com/reel/…"
|
|
value={videoUrl}
|
|
onChange={(e) => setVideoUrl(e.target.value)}
|
|
className={cn(
|
|
'bg-gray-800 border-gray-700 text-xs',
|
|
videoUrl.trim() && !isCrewVideoUrl(videoUrl) && 'border-red-500/50',
|
|
)}
|
|
/>
|
|
{videoUrl.trim() && (
|
|
isCrewVideoUrl(videoUrl) ? (
|
|
<p className="flex items-center gap-1.5 text-xs font-bold text-teal-400">
|
|
<Film className="h-3.5 w-3.5" />
|
|
{parseCrewVideo(videoUrl)?.source === 'youtube' ? t('socialUi.form.youtubeDetected') : t('socialUi.form.instagramDetected')} ✓
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-red-400">{t('socialUi.form.onlyYouTubeInstagram')}</p>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const sponsorBlock = (
|
|
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
|
|
<Label className="flex items-center gap-2 text-amber-400">
|
|
<Store className="h-4 w-4" /> {t('socialUi.sponsorBanner.sponsoredBy')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
|
</Label>
|
|
<p className="text-xs text-gray-500">
|
|
{t('socialUi.form.sponsorDesc')}
|
|
</p>
|
|
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
|
|
</div>
|
|
);
|
|
|
|
const coordinatorBlock = (
|
|
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
|
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-gray-300">
|
|
<Server className="h-4 w-4" /> {t('socialUi.coordinator.customTitle')} <span className="font-normal text-gray-500">{t('socialUi.coordinator.customBadge')}</span>
|
|
</Label>
|
|
<p className="text-xs text-gray-500">
|
|
{t('socialUi.coordinator.customDescShort')}{' '}
|
|
<a
|
|
href="https://github.com/Cordn-msg/cordn"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
|
|
>
|
|
{t('socialUi.coordinator.cordnCoordinator')}
|
|
</a>
|
|
.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="crew-coordinator"
|
|
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
|
|
value={coordinatorInput}
|
|
onChange={(e) => setCoordinatorInput(e.target.value)}
|
|
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
spellCheck={false}
|
|
/>
|
|
{coordinatorInput.trim() !== '' && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
className="shrink-0 border-gray-700 text-gray-400 hover:text-red-400"
|
|
onClick={() => setCoordinatorInput('')}
|
|
title={t('socialUi.coordinator.remove')}
|
|
aria-label={t('socialUi.coordinator.remove')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{coordinatorInput.trim() !== '' &&
|
|
(parsedCoordinator ? (
|
|
<p className="break-all text-[11px] text-teal-400">
|
|
✓ {parsedCoordinator.pubkey ? `${shortNpub(parsedCoordinator.pubkey)} · ` : ''}
|
|
{parsedCoordinator.url}
|
|
</p>
|
|
) : (
|
|
<p className="text-[11px] text-amber-400">
|
|
{t('socialUi.coordinator.invalid')}
|
|
</p>
|
|
))}
|
|
{myCoordinator && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-teal-500/40 text-teal-300 hover:bg-teal-500/10"
|
|
onClick={() => setCoordinatorInput(coordinatorAddUrl(myCoordinator))}
|
|
>
|
|
<Server className="mr-1.5 h-3.5 w-3.5" />
|
|
{t('socialUi.coordinator.useMine')}
|
|
</Button>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
|
<Input
|
|
id="crew-cordn-group"
|
|
placeholder="cordn1… · https://cordn.net/chat/…"
|
|
value={cordnGroupInput}
|
|
onChange={(e) => setCordnGroupInput(e.target.value)}
|
|
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
spellCheck={false}
|
|
/>
|
|
<p className="text-[11px] text-gray-500">{t('socialUi.form.cordnGroupHint')}</p>
|
|
</div>
|
|
{parsedCoordinator?.pubkey && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5 border-gray-700 text-gray-300 hover:bg-white/5"
|
|
onClick={() => coordinatorHealth.check()}
|
|
disabled={coordinatorHealth.status === 'checking'}
|
|
>
|
|
{coordinatorHealth.status === 'checking' ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Server className="h-3.5 w-3.5" />
|
|
)}
|
|
{t('socialUi.coordinator.check')}
|
|
</Button>
|
|
<span
|
|
className={cn(
|
|
'text-[11px] font-bold uppercase',
|
|
coordinatorHealth.status === 'online'
|
|
? 'text-teal-400'
|
|
: coordinatorHealth.status === 'stale'
|
|
? 'text-amber-400'
|
|
: coordinatorHealth.status === 'error'
|
|
? 'text-red-400'
|
|
: 'text-gray-500',
|
|
)}
|
|
>
|
|
{t(HEALTH_LABEL[coordinatorHealth.status] ?? 'socialUi.coordinator.statusIdle')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<p className="text-[10px] leading-snug text-gray-600">{t('socialUi.coordinator.pointerNote')}</p>
|
|
</div>
|
|
);
|
|
|
|
const nip05Block = (
|
|
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
|
<Label className="flex items-center gap-2 text-teal-400">
|
|
<AtSign className="h-4 w-4" /> {t('socialUi.form.nip05Label')} <span className="font-normal text-gray-500">{t('socialUi.form.optional')}</span>
|
|
</Label>
|
|
<p className="text-xs text-gray-500">
|
|
{t('socialUi.form.nip05DescA')} <code className="text-teal-300">crew@yourdomain.com</code>{t('socialUi.form.nip05DescB')}
|
|
</p>
|
|
<Nip05HandlesEditor handles={nip05s} onChange={setNip05s} pubkey={founderPubkey} inputId="crew-nip05" />
|
|
</div>
|
|
);
|
|
|
|
const linksBlock = (
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-bold">{t('socialUi.form.links')}</Label>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="crew-instagram">Instagram</Label>
|
|
<Input id="crew-instagram" placeholder="@crew" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="crew-website">{t('socialUi.form.website')}</Label>
|
|
<Input id="crew-website" type="url" placeholder="https://crew.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
{website.trim() && !sanitizeUrl(normalizeUrlInput(website)) && (
|
|
<p className="text-[11px] text-amber-400">{t('socialUi.form.invalidUrl')}</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2 sm:col-span-2">
|
|
<Label htmlFor="crew-social">{t('socialUi.form.otherSocials')}</Label>
|
|
<p className="text-xs text-gray-500">{t('socialUi.form.otherSocialsDesc')}</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="crew-social"
|
|
placeholder={t('socialUi.form.socialPlaceholder')}
|
|
value={socialInput}
|
|
onChange={(e) => setSocialInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSocial(); } }}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<Button type="button" onClick={addSocial} className={cn('shrink-0 gap-1', TEAL)}>
|
|
<Plus className="h-4 w-4" /> {t('socialUi.form.add')}
|
|
</Button>
|
|
</div>
|
|
{socials.length > 0 && (
|
|
<div className="space-y-2 pt-1">
|
|
{socials.map((s, i) => (
|
|
<div key={`${s}-${i}`} className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
|
|
<span className="min-w-0 flex-1 truncate text-sm text-gray-200">{s}</span>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
|
|
onClick={() => setSocials(socials.filter((_, idx) => idx !== i))}
|
|
aria-label={t('socialUi.form.removeSocial')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const bandBlock = (
|
|
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<Music className="h-4 w-4 text-purple-400" />
|
|
<Label className="text-base font-bold">{t('socialUi.crew.bandTitle')} <span className="text-xs font-normal text-gray-500">{t('socialUi.form.optionalParen')}</span></Label>
|
|
</div>
|
|
<p className="text-xs text-gray-500">
|
|
{t('socialUi.form.bandDesc')}
|
|
</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="crew-band-name">{t('socialUi.form.bandName')}</Label>
|
|
<Input
|
|
id="crew-band-name"
|
|
placeholder={t('socialUi.form.bandNamePlaceholder')}
|
|
value={bandName}
|
|
onChange={(e) => setBandName(e.target.value)}
|
|
maxLength={100}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="crew-band-url">{t('socialUi.form.musicLink')}</Label>
|
|
<Input
|
|
id="crew-band-url"
|
|
type="url"
|
|
placeholder="Spotify / Bandcamp / YouTube"
|
|
value={bandUrl}
|
|
onChange={(e) => setBandUrl(e.target.value)}
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
{bandUrl.trim() && !sanitizeUrl(normalizeUrlInput(bandUrl)) && (
|
|
<p className="text-[11px] text-amber-400">{t('socialUi.form.invalidUrl')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Interconnect nudge: a band should live in the Lab's Music scene. */}
|
|
{bandName.trim() && focus !== 'music' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFocus('music')}
|
|
className="flex w-full items-start gap-2 rounded-lg border border-purple-500/40 bg-purple-500/10 px-3 py-2.5 text-left transition-colors hover:bg-purple-500/20"
|
|
>
|
|
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-purple-300" />
|
|
<span className="text-xs text-purple-200">
|
|
{t('socialUi.form.bandNudgeA')} <strong>{t('socialUi.form.crewTypeWord')}</strong> {t('socialUi.form.bandNudgeB')}{' '}
|
|
<strong>{t('socialUi.form.bandMusicWord')}</strong> {t('socialUi.form.bandNudgeC')}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
<p className="flex items-start gap-1.5 text-[11px] leading-snug text-gray-500">
|
|
<Music className="mt-0.5 h-3 w-3 shrink-0 text-purple-400" />
|
|
{t('socialUi.form.bandFooterA')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.musicianWord')}</strong> {t('socialUi.form.bandFooterB')}{' '}
|
|
<strong className="text-gray-300">{t('socialUi.form.linkedLabWord')}</strong> {t('socialUi.form.bandFooterC')}
|
|
</p>
|
|
</div>
|
|
);
|
|
|
|
const busy = isPublishing || isUploading;
|
|
|
|
// ─── Edit mode: flat single page (all sections at once) ───────────────────
|
|
if (isEditing) {
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<Card className="bg-gray-900 border-gray-700">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Users className="h-5 w-5 text-teal-400" /> {t('socialUi.editCrew.title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-gray-400">{t('socialUi.form.editDesc')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{logoBlock}
|
|
{nameBlock}
|
|
{bioBlock}
|
|
{vibeBlock}
|
|
{crewTypeBlock}
|
|
{openCallBlock}
|
|
{membersBlock}
|
|
{spotsBlock}
|
|
{locationBlock}
|
|
<ExtrasGroup icon={AtSign} title={t('socialUi.form.groupIdentity')} hint={t('socialUi.form.groupIdentityHint')} defaultOpen>
|
|
{nip05Block}
|
|
{linksBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Sparkles} title={t('socialUi.form.groupAi')} defaultOpen={agentEnabled}>
|
|
{agentBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Film} title={t('socialUi.form.groupShowcase')}>
|
|
{videoBlock}
|
|
{sponsorBlock}
|
|
{bandBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Zap} title={t('socialUi.form.groupPayments')}>
|
|
{treasuryBlock}
|
|
<NofferField value={noffer} onChange={setNoffer} />
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={GraduationCap} title={t('socialUi.form.groupIntegrations')}>
|
|
{labLinkBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Server} title={t('socialUi.form.groupAdvanced')} hint={t('socialUi.form.groupAdvancedHint')}>
|
|
{coordinatorBlock}
|
|
</ExtrasGroup>
|
|
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={busy}>
|
|
{isPublishing ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.publishing')}</>
|
|
) : (
|
|
<><Users className="h-4 w-4 mr-2" />{t('socialUi.form.saveCrew')}</>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ─── Create mode: guided 4-step wizard ────────────────────────────────────
|
|
const hasExtras =
|
|
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
|
|
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
|
|
!!bandName.trim() || nip05s.length > 0 || agentEnabled || !!coordinatorInput.trim() || !!cordnGroupInput.trim() ||
|
|
(goalEnabled && !!goalTitle.trim());
|
|
|
|
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [
|
|
{
|
|
title: t('socialUi.form.stepBasics'),
|
|
short: t('socialUi.form.stepBasicsShort'),
|
|
icon: Sparkles,
|
|
hint: t('socialUi.form.stepBasicsHint'),
|
|
filled: nameValid,
|
|
content: (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col items-center gap-3 text-center sm:flex-row sm:items-start sm:text-left">
|
|
{logoBlock}
|
|
<p className="max-w-xs text-xs text-gray-500 sm:pt-8">
|
|
{t('socialUi.form.logoHint')}
|
|
</p>
|
|
</div>
|
|
{nameBlock}
|
|
{bioBlock}
|
|
{locationBlock}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: t('socialUi.form.stepVibe'),
|
|
short: t('socialUi.form.stepVibeShort'),
|
|
icon: Palette,
|
|
hint: t('socialUi.form.stepVibeHint'),
|
|
filled: vibes.length > 0 || customVibes.length > 0 || focus !== 'mixed',
|
|
content: (
|
|
<div className="space-y-6">
|
|
{vibeBlock}
|
|
{crewTypeBlock}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: t('socialUi.form.stepSquad'),
|
|
short: t('socialUi.form.stepSquadShort'),
|
|
icon: Users,
|
|
hint: t('socialUi.form.stepSquadHint'),
|
|
filled: members.length > 0 || selectedSpots.length > 0 || lookingFor.length > 0,
|
|
content: (
|
|
<div className="space-y-6">
|
|
{membersBlock}
|
|
{spotsBlock}
|
|
{openCallBlock}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: t('socialUi.form.stepExtras'),
|
|
short: t('socialUi.form.stepExtrasShort'),
|
|
icon: Plus,
|
|
hint: t('socialUi.form.stepExtrasHint'),
|
|
filled: hasExtras,
|
|
content: (
|
|
<div className="space-y-6">
|
|
<p className="text-xs text-gray-500">{t('socialUi.form.extrasAllOptional')}</p>
|
|
<ExtrasGroup icon={AtSign} title={t('socialUi.form.groupIdentity')} hint={t('socialUi.form.groupIdentityHint')} defaultOpen>
|
|
{nip05Block}
|
|
{linksBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Sparkles} title={t('socialUi.form.groupAi')} defaultOpen={agentEnabled}>
|
|
{agentBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Film} title={t('socialUi.form.groupShowcase')}>
|
|
{videoBlock}
|
|
{sponsorBlock}
|
|
{bandBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Zap} title={t('socialUi.form.groupPayments')}>
|
|
{treasuryBlock}
|
|
<NofferField value={noffer} onChange={setNoffer} />
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={GraduationCap} title={t('socialUi.form.groupIntegrations')}>
|
|
{labLinkBlock}
|
|
</ExtrasGroup>
|
|
<ExtrasGroup icon={Server} title={t('socialUi.form.groupAdvanced')} hint={t('socialUi.form.groupAdvancedHint')}>
|
|
{coordinatorBlock}
|
|
</ExtrasGroup>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
const lastStep = steps.length - 1;
|
|
const current = steps[step];
|
|
|
|
const goNext = () => {
|
|
// Only gate on the one truly-required field, at the step it lives on.
|
|
if (step === 0 && !name.trim()) {
|
|
toast({ title: t('socialUi.form.nameCrew'), description: t('socialUi.form.nameCrewDesc'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
goToStep(Math.min(step + 1, lastStep));
|
|
};
|
|
|
|
const goBack = () => goToStep(Math.max(step - 1, 0));
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
{/* Progress stepper */}
|
|
<nav className="mb-6" aria-label={t('socialUi.form.progress')}>
|
|
<ol className="flex items-center">
|
|
{steps.map((s, i) => {
|
|
const isCurrent = i === step;
|
|
const isPast = i < step;
|
|
const StepIcon = s.icon;
|
|
const jumpTo = () => {
|
|
if (i > step && !name.trim()) {
|
|
toast({ title: t('socialUi.form.nameCrewFirst'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
goToStep(i);
|
|
};
|
|
return (
|
|
<li key={s.title} className={cn('flex items-center', i < lastStep && 'flex-1')}>
|
|
<button
|
|
type="button"
|
|
onClick={jumpTo}
|
|
className="group flex flex-col items-center gap-1.5"
|
|
aria-current={isCurrent ? 'step' : undefined}
|
|
aria-label={t('socialUi.form.stepAria', { step: i + 1, title: s.title })}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'flex h-9 w-9 items-center justify-center rounded-full border-2 transition-all',
|
|
isCurrent
|
|
? 'border-teal-400 bg-teal-500/20 text-teal-300 ring-4 ring-teal-500/15 scale-110'
|
|
: isPast
|
|
? 'border-teal-500 bg-teal-500 text-black'
|
|
: s.filled
|
|
? 'border-teal-500/50 bg-teal-500/10 text-teal-400/80'
|
|
: 'border-gray-700 bg-gray-800 text-gray-500 group-hover:border-gray-600',
|
|
)}
|
|
>
|
|
{isPast || (s.filled && !isCurrent) ? (
|
|
<Check className="h-4 w-4" />
|
|
) : (
|
|
<StepIcon className="h-4 w-4" />
|
|
)}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'text-[10px] font-bold uppercase tracking-wide transition-colors',
|
|
isCurrent ? 'text-teal-300' : 'text-gray-500 group-hover:text-gray-400',
|
|
)}
|
|
>
|
|
{s.short}
|
|
</span>
|
|
</button>
|
|
{i < lastStep && (
|
|
<span
|
|
className={cn(
|
|
'mx-1 mb-5 h-0.5 flex-1 rounded-full transition-colors sm:mx-2',
|
|
i < step ? 'bg-teal-500' : 'bg-gray-700',
|
|
)}
|
|
/>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</nav>
|
|
|
|
<Card className="bg-gray-900 border-gray-700">
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2 text-[11px] font-black uppercase tracking-widest text-teal-400/70">
|
|
{t('socialUi.form.stepOf', { step: step + 1, total: steps.length })}
|
|
</div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<current.icon className="h-5 w-5 text-teal-400" /> {current.title}
|
|
</CardTitle>
|
|
<CardDescription className="text-gray-400">{current.hint}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent
|
|
key={step}
|
|
className={cn(
|
|
'animate-in fade-in duration-300',
|
|
navDir === 'forward' ? 'slide-in-from-right-2' : 'slide-in-from-left-2',
|
|
)}
|
|
>
|
|
{current.content}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Sticky footer nav */}
|
|
<div className="sticky bottom-3 z-10 mt-4 rounded-2xl border border-gray-700 bg-gray-900/95 p-3 shadow-2xl shadow-black/40 backdrop-blur supports-[backdrop-filter]:bg-gray-900/80">
|
|
{/* On the last step, remind them they're ready. */}
|
|
{step === lastStep && (
|
|
<p className="mb-2.5 flex items-center justify-center gap-1.5 text-center text-[11px] text-gray-400">
|
|
{nameValid ? (
|
|
<>
|
|
<Check className="h-3.5 w-3.5 text-teal-400" />
|
|
{t('socialUi.form.readyToRollA')} <strong className="text-teal-300">{t('socialUi.crews.createCrew')}</strong> {t('socialUi.form.readyToRollB')}
|
|
</>
|
|
) : (
|
|
<>{t('socialUi.form.addNameInBasicsA')} <strong className="text-teal-300">{t('socialUi.form.stepBasicsShort')}</strong> {t('socialUi.form.addNameInBasicsB')}</>
|
|
)}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-3">
|
|
{step > 0 ? (
|
|
<Button type="button" variant="outline" onClick={goBack} disabled={busy} className="gap-1.5 border-gray-700">
|
|
<ArrowLeftIcon className="h-4 w-4" /> {t('common.back')}
|
|
</Button>
|
|
) : (
|
|
<span className="flex-1" />
|
|
)}
|
|
|
|
{step < lastStep ? (
|
|
<>
|
|
{step > 0 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
if (!name.trim()) {
|
|
toast({ title: t('socialUi.form.nameCrewFirst'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
goToStep(lastStep);
|
|
}}
|
|
disabled={busy}
|
|
className="text-xs text-gray-400 hover:text-white sm:text-sm"
|
|
>
|
|
{t('socialUi.form.skipToEnd')}
|
|
</Button>
|
|
)}
|
|
<Button type="button" onClick={goNext} disabled={busy} className={cn('flex-1 gap-1.5 font-black uppercase', TEAL)}>
|
|
{t('socialUi.form.continue')} <ArrowRight className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button type="submit" disabled={busy || !nameValid} className={cn('flex-1 font-black uppercase', TEAL)}>
|
|
{isPublishing ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('socialUi.form.publishing')}</>
|
|
) : (
|
|
<><Users className="h-4 w-4 mr-2" />{t('socialUi.crews.createCrew')}</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|