Reuse organization banner card for campaign title step

This commit is contained in:
lemon
2026-06-13 00:14:33 -07:00
parent 6ac6c0b22f
commit a7a9ed06a3
4 changed files with 84 additions and 16 deletions
+17 -4
View File
@@ -33,11 +33,13 @@ function EditableInput({
value,
placeholder,
onChange,
maxLength,
className,
}: {
value: string;
placeholder: string;
onChange: (v: string) => void;
maxLength?: number;
className?: string;
}) {
return (
@@ -45,6 +47,7 @@ function EditableInput({
type="text"
value={value}
placeholder={placeholder}
maxLength={maxLength}
onChange={(e) => onChange(e.target.value)}
className={cn(editableBase, 'w-full min-w-0 py-0.5', className)}
/>
@@ -150,6 +153,12 @@ interface ProfileCardProps {
onRemoveBanner?: () => void;
/** Show the banner area (default true). When false, only the avatar shows. */
showBanner?: boolean;
/** Show the avatar area (default true). */
showAvatar?: boolean;
/** Placeholder for the editable name input. */
namePlaceholder?: string;
/** Maximum length for the editable name input. */
nameMaxLength?: number;
/** Show NIP-05 row (default true) */
showNip05?: boolean;
/** Show NIP-58 badge showcase row (default true). */
@@ -178,6 +187,9 @@ export function ProfileCard({
onRemoveAvatar,
onRemoveBanner,
showBanner = true,
showAvatar = true,
namePlaceholder = 'Your name',
nameMaxLength,
showNip05 = true,
showBadges = true,
bioField = 'about',
@@ -284,10 +296,10 @@ export function ProfileCard({
))}
{/* Profile info */}
<div className="px-4 pb-4">
<div className={cn('px-4 pb-4', !showAvatar && (showBanner ? 'pt-3' : 'pt-4'))}>
{/* Avatar */}
<div className={cn('flex justify-between items-start mb-3', showBanner ? '-mt-12' : 'mt-3')}>
{showAvatar && <div className={cn('flex justify-between items-start mb-3', showBanner ? '-mt-12' : 'mt-3')}>
{editable ? (
<ImageEditMenu
hasImage={!!metadata.picture}
@@ -323,13 +335,14 @@ export function ProfileCard({
</Avatar>
</div>
)}
</div>
</div>}
{/* Name */}
{editable ? (
<EditableInput
value={metadata.name ?? ''}
placeholder="Your name"
placeholder={namePlaceholder}
maxLength={nameMaxLength}
onChange={patch('name')}
className="text-xl font-bold"
/>
+14 -4
View File
@@ -63,6 +63,8 @@ export interface WizardProps {
launchNowLabel?: string;
onSubmit: (e: FormEvent) => void;
onClose: () => void;
/** Optional back action for step 1 when there is a meaningful previous flow. */
onBackFromFirstStep?: () => void;
}
/**
@@ -103,6 +105,7 @@ export function Wizard({
launchNowLabel,
onSubmit,
onClose,
onBackFromFirstStep,
}: WizardProps) {
const { t } = useTranslation();
const [step, setStep] = useState(1);
@@ -124,6 +127,7 @@ export function Wizard({
const canSubmit = isTerminal
? !submitting && !isAdvancing
: launchVisible && canAdvance && !submitting && !isAdvancing;
const backVisible = step > 1 || !!onBackFromFirstStep;
const handleAdvance = async () => {
if (submitting || isAdvancing || !canAdvance) return;
@@ -169,12 +173,18 @@ export function Wizard({
</button>
{/* Top-left back. Mirrors the close button so the user can step
back through the wizard without scrolling to the footer. Only
rendered from step 2 onward — step 1's escape route is the X. */}
{step > 1 && (
back through the wizard without scrolling to the footer. Step 1
only renders it when the host provides an external back target. */}
{backVisible && (
<button
type="button"
onClick={() => setStep((s) => Math.max(s - 1, 1))}
onClick={() => {
if (step === 1) {
onBackFromFirstStep?.();
} else {
setStep((s) => Math.max(s - 1, 1));
}
}}
disabled={submitting || isAdvancing}
aria-label={t('common.back')}
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
@@ -48,6 +48,14 @@ interface ProfileIdentityEditorProps {
aboutPlaceholder?: string;
/** Show the banner area (default true). */
showBanner?: boolean;
/** Show the avatar area (default true). */
showAvatar?: boolean;
/** Placeholder for the editable name input. */
namePlaceholder?: string;
/** Maximum length for the editable name input. */
nameMaxLength?: number;
/** Notifies the host with the Blossom/NIP-94 tags from a completed image upload. */
onImageUploadComplete?: (field: 'picture' | 'banner', nip94Tags: string[][]) => void;
/** Notifies the host of upload progress so it can gate its primary button. */
onUploadingChange?: (uploading: boolean) => void;
className?: string;
@@ -69,6 +77,10 @@ export function ProfileIdentityEditor({
bioField,
aboutPlaceholder,
showBanner = true,
showAvatar = true,
namePlaceholder,
nameMaxLength,
onImageUploadComplete,
onUploadingChange,
className,
}: ProfileIdentityEditorProps) {
@@ -181,14 +193,17 @@ export function ProfileIdentityEditor({
try {
const tags = await uploadFile(croppedFile);
const url = tags[0]?.[1];
if (url) onChange({ [field]: url });
if (url) {
onChange({ [field]: url });
onImageUploadComplete?.(field, tags);
}
} catch {
toast({ title: t('onboarding.profile.uploadFailed'), variant: 'destructive' });
} finally {
onUploadingChange?.(false);
}
},
[cropState, uploadFile, onChange, onUploadingChange, t, toast],
[cropState, uploadFile, onChange, onImageUploadComplete, onUploadingChange, t, toast],
);
const handleCropError = useCallback(
@@ -250,6 +265,9 @@ export function ProfileIdentityEditor({
bioField={bioField}
aboutPlaceholder={aboutPlaceholder}
showBanner={showBanner}
showAvatar={showAvatar}
namePlaceholder={namePlaceholder}
nameMaxLength={nameMaxLength}
showNip05={false}
showBadges={false}
/>
+33 -6
View File
@@ -45,6 +45,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 {
@@ -136,6 +137,7 @@ export function CreateCampaignPage() {
const queryClient = useQueryClient();
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const { role: onboardingRole, startSignup } = useOnboarding();
const { toast } = useToast();
const hdWallet = useHdWallet();
const hdWalletAvailable = hdWallet.availability.status === 'available';
@@ -881,6 +883,35 @@ export function CreateCampaignPage() {
</FormSection>
);
const campaignIdentitySection = (
<ProfileIdentityEditor
className={cn(coverUploading && 'opacity-50 pointer-events-none')}
draft={{
name: title,
website: '',
about: '',
picture: '',
banner: bannerUrl,
}}
onChange={(patch) => {
if (patch.name !== undefined) setTitle(patch.name);
if (patch.banner !== undefined) {
setBannerUrl(patch.banner);
setBannerNip94Tags(null);
}
}}
bioField="none"
showBanner
showAvatar={false}
namePlaceholder="Campaign title"
nameMaxLength={200}
onUploadingChange={setCoverUploading}
onImageUploadComplete={(field, nip94Tags) => {
if (field === 'banner') setBannerNip94Tags(nip94Tags);
}}
/>
);
const storySection = (
<Textarea
id="campaign-story"
@@ -1066,12 +1097,7 @@ export function CreateCampaignPage() {
{
title: t('campaignsCreate.wizard.titleStepTitle'),
subtitle: t('campaignsCreate.wizard.titleStepSubtitle'),
body: (
<>
{bannerSection}
{titleSection}
</>
),
body: campaignIdentitySection,
},
{
title: t('campaignsCreate.wizard.walletStepTitle'),
@@ -1135,6 +1161,7 @@ export function CreateCampaignPage() {
submitting={submitMutation.isPending || profileMutation.isPending || coverUploading || profileImageUploading}
onSubmit={handleSubmit}
onClose={() => navigate(-1)}
onBackFromFirstStep={onboardingRole === 'creator' ? () => startSignup() : undefined}
/>
);
}