SkateSpots/src/components/CrewForm.tsx

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