Reuse organization banner card for campaign title step
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user