onboarding: hide bio behind Advanced toggle; show profile step first for new campaign creators

This commit is contained in:
Chad Curtis
2026-05-30 20:14:56 -05:00
parent f5cdbb6f3a
commit c845f7286b
20 changed files with 108 additions and 25 deletions
+48 -21
View File
@@ -12,6 +12,7 @@ import {
ArrowLeft,
ArrowRight,
Bitcoin,
ChevronDown,
Download,
Eye,
EyeOff,
@@ -92,7 +93,7 @@ export function OnboardingGate({ children }: { children: ReactNode }) {
* per-flow state resets cleanly between sessions. */
function CaptiveOverlay() {
const { t } = useTranslation();
const { cancel, role: contextRole, setRole: setContextRole } = useOnboarding();
const { cancel, role: contextRole, setRole: setContextRole, skipToProfile } = useOnboarding();
const navigate = useNavigate();
const { toast } = useToast();
const login = useLoginActions();
@@ -100,13 +101,16 @@ function CaptiveOverlay() {
const { mutateAsync: publishEvent, isPending: isPublishingProfile } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploadingAvatar } = useUploadFile();
// Decide the entry step. Already-authenticated users (e.g. a CTA called
// startSignup() on a logged-in surface to walk them to the role picker)
// skip keygen / secure / profile and land on `role` directly.
// Decide the entry step.
// - Already-authenticated users normally land on `role` directly.
// - If `skipToProfile` is set (e.g. campaign creation for a user with no
// profile), they land on `profile` first; after finishing we navigate
// straight to the role destination without showing the role picker.
const initialStep: Step = useMemo(() => {
if (user && skipToProfile) return 'profile';
if (user) return 'role';
return 'keygen';
}, [user]);
}, [user, skipToProfile]);
const [step, setStep] = useState<Step>(initialStep);
@@ -227,10 +231,19 @@ function CaptiveOverlay() {
variant: 'destructive',
});
} finally {
goTo('role');
// When the overlay was opened with skipToProfile (e.g. from
// /campaigns/new for a user without a profile), skip the role
// picker and navigate directly to the pre-seeded role destination.
if (skipToProfile && contextRole) {
cancel();
if (contextRole === 'creator') navigate('/campaigns/new');
else navigate('/campaigns');
} else {
goTo('role');
}
}
},
[profileData, publishEvent, toast, t, goTo],
[profileData, publishEvent, toast, t, goTo, skipToProfile, contextRole, cancel, navigate],
);
// Step renderer -----------------------------------------------------------
@@ -577,6 +590,7 @@ function ProfileStep({
onSkip,
}: ProfileStepProps) {
const { t } = useTranslation();
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
@@ -597,20 +611,6 @@ function ProfileStep({
/>
</div>
<div className="space-y-1.5">
<label htmlFor="onb-profile-about" className="text-sm font-medium">
{t('onboarding.profile.aboutLabel')}
</label>
<Textarea
id="onb-profile-about"
value={data.about}
onChange={(e) => onChange({ about: e.target.value })}
placeholder={t('onboarding.profile.aboutPlaceholder')}
className="resize-none"
rows={3}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="onb-profile-picture" className="text-sm font-medium">
{t('onboarding.profile.avatarLabel')}
@@ -646,6 +646,33 @@ function ProfileStep({
</Button>
</div>
</div>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronDown
className={cn('h-4 w-4 transition-transform duration-200', showAdvanced && 'rotate-180')}
/>
{t('onboarding.profile.advanced')}
</button>
{showAdvanced && (
<div className="space-y-1.5">
<label htmlFor="onb-profile-about" className="text-sm font-medium">
{t('onboarding.profile.aboutLabel')}
</label>
<Textarea
id="onb-profile-about"
value={data.about}
onChange={(e) => onChange({ about: e.target.value })}
placeholder={t('onboarding.profile.aboutPlaceholder')}
className="resize-none"
rows={3}
/>
</div>
)}
</div>
<div className="space-y-2">
+3 -1
View File
@@ -20,9 +20,11 @@ import {
export function OnboardingProvider({ children }: { children: ReactNode }) {
const [active, setActive] = useState(false);
const [role, setRoleState] = useState<OnboardingRole>(null);
const [skipToProfile, setSkipToProfile] = useState(false);
const startSignup = useCallback((options?: StartSignupOptions) => {
setRoleState(options?.role ?? null);
setSkipToProfile(options?.skipToProfile ?? false);
setActive(true);
}, []);
@@ -37,7 +39,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
}, []);
return (
<OnboardingContext.Provider value={{ active, role, startSignup, cancel, setRole }}>
<OnboardingContext.Provider value={{ active, role, skipToProfile, startSignup, cancel, setRole }}>
{children}
</OnboardingContext.Provider>
);
+12
View File
@@ -16,6 +16,13 @@ export interface StartSignupOptions {
* (e.g. "Start a campaign") can skip the role step by passing this.
*/
role?: 'creator' | 'donor';
/**
* When `true` and the user is already logged in, jump directly to the
* profile step instead of the role picker. Used by surfaces like
* `/campaigns/new` that want the user to set up a profile first.
* After the profile step completes the flow navigates based on `role`.
*/
skipToProfile?: boolean;
}
export interface OnboardingContextValue {
@@ -23,6 +30,11 @@ export interface OnboardingContextValue {
active: boolean;
/** Selected role, or `null` if the picker hasn't run / been skipped. */
role: OnboardingRole;
/**
* When `true`, the overlay was opened with `skipToProfile` so an
* already-logged-in user sees the profile step first.
*/
skipToProfile: boolean;
/** Begin the captive signup flow. Optionally pre-seed the role. */
startSignup: (options?: StartSignupOptions) => void;
/** Cancel and dismiss the overlay. Called from the gate when the user
+1
View File
@@ -113,6 +113,7 @@
"aboutPlaceholder": "نبذة قصيرة عنك…",
"avatarLabel": "الصورة الرمزية",
"uploadAvatar": "رفع صورة رمزية",
"advanced": "متقدّم",
"finish": "إنهاء",
"saving": "جارٍ الحفظ…",
"skip": "تخطٍّ في الوقت الحالي",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "A little about you…",
"avatarLabel": "Avatar",
"uploadAvatar": "Upload avatar",
"advanced": "Advanced",
"finish": "Finish",
"saving": "Saving…",
"skip": "Skip for now",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "Un poco sobre ti…",
"avatarLabel": "Avatar",
"uploadAvatar": "Subir avatar",
"advanced": "Avanzado",
"finish": "Finalizar",
"saving": "Guardando…",
"skip": "Omitir por ahora",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "کمی دربارهٔ شما…",
"avatarLabel": "آواتار",
"uploadAvatar": "بارگذاری آواتار",
"advanced": "پیشرفته",
"finish": "پایان",
"saving": "در حال ذخیره…",
"skip": "فعلاً رد شو",
+1
View File
@@ -116,6 +116,7 @@
"aboutPlaceholder": "Un mot sur vous…",
"avatarLabel": "Avatar",
"uploadAvatar": "Téléverser un avatar",
"advanced": "Avancé",
"finish": "Terminer",
"saving": "Enregistrement…",
"skip": "Passer pour l'instant",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "अपने बारे में थोड़ा सा…",
"avatarLabel": "अवतार",
"uploadAvatar": "अवतार अपलोड करें",
"advanced": "उन्नत",
"finish": "पूरा करें",
"saving": "सेव हो रहा है…",
"skip": "अभी छोड़ दें",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "Sedikit tentang Anda…",
"avatarLabel": "Avatar",
"uploadAvatar": "Unggah avatar",
"advanced": "Lanjutan",
"finish": "Selesai",
"saving": "Menyimpan…",
"skip": "Lewati dulu",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "បន្តិចបន្តួចអំពីអ្នក…",
"avatarLabel": "រូបតំណាង",
"uploadAvatar": "ផ្ទុករូបតំណាង",
"advanced": "កម្រិតខ្ពស់",
"finish": "បញ្ចប់",
"saving": "កំពុងរក្សាទុក…",
"skip": "រំលងជាមុនសិន",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "د ځان په اړه یو څه…",
"avatarLabel": "اواتار",
"uploadAvatar": "اواتار پورته کول",
"advanced": "پرمختللي",
"finish": "پای",
"saving": "خوندي کېږي…",
"skip": "اوس یې پرېږدئ",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "Um pouco sobre você…",
"avatarLabel": "Avatar",
"uploadAvatar": "Enviar avatar",
"advanced": "Avançado",
"finish": "Concluir",
"saving": "Salvando…",
"skip": "Pular por enquanto",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "Немного о вас…",
"avatarLabel": "Аватар",
"uploadAvatar": "Загрузить аватар",
"advanced": "Дополнительно",
"finish": "Готово",
"saving": "Сохранение…",
"skip": "Пропустить",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "Zvishoma pamusoro pako…",
"avatarLabel": "Avatar",
"uploadAvatar": "Isa avatar",
"advanced": "Yakadzama",
"finish": "Pedzisa",
"saving": "Kuchengeta…",
"skip": "Pfuura zvinosvika",
+1
View File
@@ -116,6 +116,7 @@
"aboutPlaceholder": "Kidogo kukuhusu…",
"avatarLabel": "Avatari",
"uploadAvatar": "Pakia avatari",
"advanced": "Zaidi",
"finish": "Maliza",
"saving": "Inahifadhi…",
"skip": "Ruka kwa sasa",
+1
View File
@@ -116,6 +116,7 @@
"aboutPlaceholder": "Kendiniz hakkında birkaç söz…",
"avatarLabel": "Avatar",
"uploadAvatar": "Avatar yükle",
"advanced": "Gelişmiş",
"finish": "Bitir",
"saving": "Kaydediliyor…",
"skip": "Şimdilik atla",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "簡單介紹一下你自己…",
"avatarLabel": "頭像",
"uploadAvatar": "上傳頭像",
"advanced": "進階",
"finish": "完成",
"saving": "儲存中…",
"skip": "暫時略過",
+1
View File
@@ -117,6 +117,7 @@
"aboutPlaceholder": "介绍一下你自己……",
"avatarLabel": "头像",
"uploadAvatar": "上传头像",
"advanced": "高级",
"finish": "完成",
"saving": "正在保存……",
"skip": "暂时跳过",
+29 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -39,6 +39,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useHdWallet } from '@/hooks/useHdWallet';
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useOnboarding } from '@/contexts/onboardingContextDef';
import { useToast } from '@/hooks/useToast';
import { formatBTC, satsToUSD } from '@/lib/bitcoin';
import {
@@ -131,6 +132,33 @@ export function CreateCampaignPage() {
? (userMetadata?.name ?? userMetadata?.display_name ?? genUserName(user.pubkey))
: '';
const { startSignup, active: onboardingActive } = useOnboarding();
// Track whether we've already triggered the profile gate so the effect
// doesn't re-fire on subsequent renders (e.g. when the overlay dismisses
// and userAuthor re-renders with data).
const profileGateFiredRef = useRef(false);
// If a logged-in user navigates to /campaigns/new without a kind-0 profile,
// open the onboarding profile step so they set a name before the campaign
// wizard loads. Only runs once per page mount and only in create mode.
const editNaddr = searchParams.get('edit');
const isEditMode = !!editNaddr;
useEffect(() => {
if (isEditMode) return;
if (!user) return;
if (userAuthor.isLoading) return;
if (profileGateFiredRef.current) return;
if (onboardingActive) return;
const hasProfile = !!(userMetadata?.name || userMetadata?.display_name);
if (!hasProfile) {
profileGateFiredRef.current = true;
startSignup({ role: 'creator', skipToProfile: true });
}
}, [isEditMode, user, userAuthor.isLoading, userMetadata, onboardingActive, startSignup]);
const [title, setTitle] = useState('');
const [story, setStory] = useState('');
const [bannerUrl, setBannerUrl] = useState('');
@@ -189,9 +217,7 @@ export function CreateCampaignPage() {
const [formError, setFormError] = useState('');
const [prepopulatedEventId, setPrepopulatedEventId] = useState<string | null>(null);
const editNaddr = searchParams.get('edit');
const editTarget = useMemo(() => getEditTarget(editNaddr), [editNaddr]);
const isEditMode = !!editNaddr;
// ── Organization context (implicit) ────────────────────────────────────
const orgParam = searchParams.get('org');