feat(crew): tidy the create-crew "Extras" step into collapsible groups

The Extras step stacked 9 ungrouped blocks. Now:
- Identity (NIP-05, links) — open by default.
- Sasquatch AI — open when the agent is on.
- Showcase (video, sponsor, band).
- Payments & treasury (treasury + the CLINK tip jar, moved out of "Links").
- Integrations (Lab link).
- Advanced (Coordinator) — collapsed + last, so a new founder isn't asked
  about experimental plumbing.

Other fixes:
- The crew AI agent is now labelled "Sasquatch · Pro" (it runs on the same
  Pro-gated Sasquatch proxy, falling back to a free model), with an honest note.
- crew founded year is clamped to 1954..current on save and bounded in the input.
- "Skip to end" is no longer hidden on mobile.
- Removed the coordinator copy keys left unused by the previous pass.
This commit is contained in:
SkateSpots 2026-10-03 18:02:12 +07:00
parent c93d7e4e41
commit 0548a7a7c6
3 changed files with 112 additions and 32 deletions

View file

@ -3,6 +3,7 @@ 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';
@ -29,7 +30,7 @@ import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } 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 } from 'lucide-react';
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> = {
@ -201,6 +202,37 @@ const HEALTH_LABEL: Record<string, string> = {
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();
@ -510,7 +542,13 @@ export function CrewForm({ existing }: CrewFormProps) {
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()) tags.push(['founded', founded.trim()]);
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]);
if (website.trim()) tags.push(['website', website.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
@ -671,7 +709,7 @@ export function CrewForm({ existing }: CrewFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="crew-founded">{t('socialUi.form.founded')}</Label>
<Input id="crew-founded" type="number" placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
<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>
@ -816,13 +854,15 @@ export function CrewForm({ existing }: CrewFormProps) {
className="h-5 w-5 rounded-md object-cover"
loading="lazy"
/>
{t('socialUi.crew.agent')}
{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"
@ -1273,7 +1313,6 @@ export function CrewForm({ existing }: CrewFormProps) {
)}
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
</div>
);
@ -1360,15 +1399,28 @@ export function CrewForm({ existing }: CrewFormProps) {
{membersBlock}
{spotsBlock}
{locationBlock}
{nip05Block}
{linksBlock}
{bandBlock}
{agentBlock}
{videoBlock}
{sponsorBlock}
{treasuryBlock}
{labLinkBlock}
{coordinatorBlock}
<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')}</>
@ -1445,15 +1497,29 @@ export function CrewForm({ existing }: CrewFormProps) {
filled: hasExtras,
content: (
<div className="space-y-6">
{nip05Block}
{linksBlock}
{bandBlock}
{agentBlock}
{videoBlock}
{sponsorBlock}
{treasuryBlock}
{labLinkBlock}
{coordinatorBlock}
<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>
),
},
@ -1598,7 +1664,7 @@ export function CrewForm({ existing }: CrewFormProps) {
goToStep(lastStep);
}}
disabled={busy}
className="hidden text-gray-400 hover:text-white sm:inline-flex"
className="text-xs text-gray-400 hover:text-white sm:text-sm"
>
{t('socialUi.form.skipToEnd')}
</Button>

View file

@ -3603,11 +3603,7 @@ socialUi: {
customDescShort: "Optional — your crew already has end-to-end encrypted chat built in, so you don't need this. Only set it if you run your own",
remove: 'Remove',
pointerNote: "Pointer only for now: a wss:// endpoint is used as the crew's chat relay; an https:// one via the shared ContextVM relay. Real Cordn MLS isn't wired yet.",
customDescA: "You don't need this.",
customDescB: 'Your crew already has private, end-to-end encrypted group chat built in — no setup. This field is only for crews who',
customRunOwn: 'run their own',
cordnCoordinator: 'Cordn coordinator',
customDescC: "— even on an old phone. Paste its nprofile, a cordn.net link, or an endpoint URL. A wss:// endpoint is used as the crew's own chat relay (point it at a private one to keep even the metadata off public relays); an https:// coordinator is addressed by its pubkey over the shared ContextVM relay. Leave blank to use the built-in chat.",
invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.",
useMine: 'Use my coordinator',
},
@ -3899,6 +3895,17 @@ socialUi: {
stepExtras: 'Extras',
stepExtrasShort: 'Extras',
stepExtrasHint: 'Power-ups for later — skip anything you’re unsure about.',
extrasAllOptional: 'Everything on this step is optional.',
groupIdentity: 'Identity',
groupIdentityHint: 'Handle, links',
groupAi: 'Sasquatch AI',
groupShowcase: 'Showcase',
groupPayments: 'Payments & treasury',
groupIntegrations: 'Integrations',
groupAdvanced: 'Advanced',
groupAdvancedHint: 'experimental',
pro: 'Pro',
agentProNote: 'Powered by Sasquatch. Pro gets the best models; without Pro it uses a free fallback.',
stepOf: 'Step {{step}} of {{total}}',
treasuryDescA: "Raise sats for a shared goal — fixing the local DIY spot, a crew trip, a comp prize pot. It's a transparent Lightning goal; zaps go",
treasuryDescStrong: 'straight to your wallet',

View file

@ -3603,11 +3603,7 @@ socialUi: {
customDescShort: 'Opcional: tu cuadrilla ya tiene chat cifrado de extremo a extremo integrado, así que no lo necesitas. Configúralo solo si usas tu propio',
remove: 'Quitar',
pointerNote: 'Por ahora es solo un puntero: un endpoint wss:// se usa como relé del chat de la cuadrilla y uno https:// a través del relé ContextVM compartido. El MLS real de Cordn aún no está conectado.',
customDescA: 'No lo necesitas.',
customDescB: 'Tu cuadrilla ya tiene chat de grupo privado y cifrado de extremo a extremo integrado — sin configurar nada. Este campo es solo para cuadrillas que',
customRunOwn: 'montan el suyo',
cordnCoordinator: 'coordinador Cordn',
customDescC: '— incluso en un móvil viejo. Pega su nprofile, un enlace de cordn.net o una URL de endpoint. Un endpoint wss:// se usa como relé de chat propio de la cuadrilla (apúntalo a uno privado para mantener hasta los metadatos fuera de los relés públicos); un coordinador https:// se direcciona por su pubkey a través del relé ContextVM compartido. Déjalo en blanco para usar el chat integrado.',
invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.',
useMine: 'Usar mi coordinador',
},
@ -3899,6 +3895,17 @@ socialUi: {
stepExtras: 'Extras',
stepExtrasShort: 'Extras',
stepExtrasHint: 'Mejoras para más adelante — sáltate lo que no tengas claro.',
extrasAllOptional: 'Todo en este paso es opcional.',
groupIdentity: 'Identidad',
groupIdentityHint: 'Identificador, enlaces',
groupAi: 'IA Sasquatch',
groupShowcase: 'Escaparate',
groupPayments: 'Pagos y tesorería',
groupIntegrations: 'Integraciones',
groupAdvanced: 'Avanzado',
groupAdvancedHint: 'experimental',
pro: 'Pro',
agentProNote: 'Con la tecnología de Sasquatch. Pro usa los mejores modelos; sin Pro usa un respaldo gratuito.',
stepOf: 'Paso {{step}} de {{total}}',
treasuryDescA: 'Recauda sats para un objetivo común — arreglar el spot DIY local, un viaje de la cuadrilla, un bote para un concurso. Es un objetivo Lightning transparente; los zaps van',
treasuryDescStrong: 'directos a tu monedero',