Redesign the wizard's wallet step around the user's wallet card
The wallet step previously stacked two generic dropdowns (source + accept) on top of two custom-address inputs that the user had to expand explicitly. Every donation flow starts the same way: pick "my wallet" and accept everything. The redesign treats that path as the default view, not one of two dropdown options. What changed: * The Source dropdown becomes an inline identity card — avatar + display name on the left, live USD/BTC balance on the right (modeled on the wallet-page treatment), pencil affordance on the far right. Tapping anywhere on the card swaps the view into the custom-wallet inputs; a quieter "Use a custom wallet instead" sub-link beneath it offers the same swap. From custom mode a small "← Use my Agora wallet" mirror-link snaps back. * The Accept dropdown becomes a three-pill segmented ToggleGroup — All / Public / Private — with icons (sparkles / bitcoin / radar) and a one-line caption beneath that explains the current selection. The All and Private buttons disable when silent payments aren't supported by the current login. Default stays 'all' (HD wallet with SP); empty toggle deselects are coerced back to the previous value since the field is required. * Balance comes from the parent's existing useHdWallet hook (passed in via new `totalBalance` + `balanceLoading` props) plus an in-component useBtcPrice call. Loading state shows a small Skeleton in place of the price line; missing price falls back to BTC-only. * When no HD wallet is available (extension / bunker logins) the picker collapses to just the two custom inputs with the existing intro copy — no card, no toggle. Existing locale keys are reused where the strings still fit; new ones cover the toggle short labels, the captions, and the swap affordances. The wider "Custom" label widens to "Custom wallet" so the segmented header reads cleanly. Other locales fall back to English on the new keys until the copy settles.
This commit is contained in:
+10
-1
@@ -916,10 +916,19 @@
|
||||
"myWalletLabel": "{{name}}'s wallet",
|
||||
"myWalletDefault": "My wallet",
|
||||
"walletChoose": "Choose a wallet",
|
||||
"walletCustom": "Custom",
|
||||
"walletCustom": "Custom wallet",
|
||||
"walletEditAria": "Change destination wallet",
|
||||
"walletUseCustom": "Use a custom wallet instead",
|
||||
"walletUseMine": "Use my Agora wallet",
|
||||
"acceptAll": "Accept all payment types",
|
||||
"acceptPublic": "Accept public payments only",
|
||||
"acceptPrivate": "Accept private payments only",
|
||||
"acceptAllShort": "All",
|
||||
"acceptPublicShort": "Public",
|
||||
"acceptPrivateShort": "Private",
|
||||
"acceptAllHint": "Accept both public on-chain and private silent payments.",
|
||||
"acceptPublicHint": "Only accept on-chain donations to a public address.",
|
||||
"acceptPrivateHint": "Only accept silent payments — donor addresses stay private.",
|
||||
"customWalletIntro": "Enter a Bitcoin address, a silent-payment code, or both. At least one is required.",
|
||||
"bitcoinAddress": "Bitcoin address",
|
||||
"bitcoinAddressPlaceholder": "bc1q… or bc1p…",
|
||||
|
||||
@@ -9,10 +9,14 @@ import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Bitcoin,
|
||||
HandHeart,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Radar,
|
||||
Sparkles,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -27,23 +31,20 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCampaign } from '@/hooks/useCampaign';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { formatBTC, satsToUSD } from '@/lib/bitcoin';
|
||||
import {
|
||||
CAMPAIGN_KIND,
|
||||
encodeCampaignNaddr,
|
||||
@@ -655,6 +656,8 @@ export function CreateCampaignPage() {
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
displayName={userDisplayName}
|
||||
picture={userMetadata?.picture}
|
||||
totalBalance={hdWallet.totalBalance}
|
||||
balanceLoading={hdWalletAvailable && hdWallet.isLoading}
|
||||
walletSource={walletSource}
|
||||
onWalletSourceChange={setWalletSource}
|
||||
mineAccept={mineAccept}
|
||||
@@ -1160,27 +1163,31 @@ function CampaignWizard({
|
||||
/**
|
||||
* Wallet picker for the campaign form.
|
||||
*
|
||||
* For nsec users we render two dropdowns:
|
||||
* Two modes selectable via a single inline toggle:
|
||||
*
|
||||
* 1. **Source** — "My wallet" (default) or "Custom".
|
||||
* 2. **Accept** — only shown when source is "My wallet". Picks which
|
||||
* donation types the HD-wallet campaign accepts: all, public only,
|
||||
* or private only.
|
||||
* 1. **My wallet** (`'mine'`, default when nsec is available) — a
|
||||
* compact identity card shows the user's avatar, display name and
|
||||
* live USD/BTC balance, modelled on the wallet-page balance
|
||||
* treatment. A small pencil affordance to the right is the entry
|
||||
* point to swap into custom mode; mirror-link beneath the inputs
|
||||
* swaps back. The HD-wallet mode also surfaces a segmented
|
||||
* "Accept" picker (All / Public / Private) that picks which
|
||||
* donation types the campaign accepts.
|
||||
* 2. **Custom** (`'custom'`) — two address inputs (on-chain + silent
|
||||
* payment). At least one must parse to a valid endpoint of its
|
||||
* mode.
|
||||
*
|
||||
* For users without nsec access (extension / bunker logins) the
|
||||
* dropdowns are skipped entirely and we show the two custom inputs
|
||||
* directly — that's the only path to a wallet endpoint for those
|
||||
* logins.
|
||||
*
|
||||
* When source is "Custom" the user can fill the on-chain address, the
|
||||
* silent-payment code, or both. At least one must parse to a valid
|
||||
* endpoint of its mode.
|
||||
* Users without nsec access (extension / bunker logins) never see the
|
||||
* "mine" branch — `hdWalletAvailable` is false and we drop straight
|
||||
* to the custom inputs.
|
||||
*/
|
||||
function WalletPicker({
|
||||
hdWalletAvailable,
|
||||
silentPaymentSupported,
|
||||
displayName,
|
||||
picture,
|
||||
totalBalance,
|
||||
balanceLoading,
|
||||
walletSource,
|
||||
onWalletSourceChange,
|
||||
mineAccept,
|
||||
@@ -1196,6 +1203,10 @@ function WalletPicker({
|
||||
silentPaymentSupported: boolean;
|
||||
displayName: string;
|
||||
picture?: string;
|
||||
/** Live HD-wallet balance in sats (confirmed + pending + SP). */
|
||||
totalBalance: number;
|
||||
/** True while the initial HD scan is still running — drives skeleton. */
|
||||
balanceLoading: boolean;
|
||||
walletSource: 'mine' | 'custom';
|
||||
onWalletSourceChange: (value: 'mine' | 'custom') => void;
|
||||
mineAccept: 'all' | 'public' | 'private';
|
||||
@@ -1208,85 +1219,122 @@ function WalletPicker({
|
||||
parsedCustomSp: ReturnType<typeof parseCampaignWallet>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const initial = displayName.charAt(0).toUpperCase() || '?';
|
||||
const myWalletLabel = displayName
|
||||
? t('campaignsCreate.myWalletLabel', { name: displayName })
|
||||
: t('campaignsCreate.myWalletDefault');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{hdWalletAvailable ? (
|
||||
<>
|
||||
<Select value={walletSource} onValueChange={(v) => onWalletSourceChange(v as 'mine' | 'custom')}>
|
||||
<SelectTrigger className="h-12">
|
||||
<SelectValue placeholder={t('campaignsCreate.walletChoose')}>
|
||||
{walletSource === 'custom' ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Wallet className="size-3.5" />
|
||||
</span>
|
||||
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-sm">{myWalletLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mine">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{myWalletLabel}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Wallet className="size-3.5" />
|
||||
</span>
|
||||
<span className="text-sm">{t('campaignsCreate.walletCustom')}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{walletSource === 'mine' && (
|
||||
<Select
|
||||
value={mineAccept}
|
||||
onValueChange={(v) => onMineAcceptChange(v as 'all' | 'public' | 'private')}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" disabled={!silentPaymentSupported}>
|
||||
{t('campaignsCreate.acceptAll')}
|
||||
</SelectItem>
|
||||
<SelectItem value="public">{t('campaignsCreate.acceptPublic')}</SelectItem>
|
||||
<SelectItem value="private" disabled={!silentPaymentSupported}>
|
||||
{t('campaignsCreate.acceptPrivate')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// When no HD wallet is available (extension / bunker login) there's
|
||||
// no "mine" branch to choose — render only the custom inputs with a
|
||||
// short intro line so the user understands what's expected.
|
||||
if (!hdWalletAvailable) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('campaignsCreate.customWalletIntro')}
|
||||
</p>
|
||||
)}
|
||||
<CustomWalletInput
|
||||
id="campaign-wallet-onchain"
|
||||
label={t('campaignsCreate.bitcoinAddress')}
|
||||
placeholder={t('campaignsCreate.bitcoinAddressPlaceholder')}
|
||||
value={customOnchain}
|
||||
onChange={onCustomOnchainChange}
|
||||
parsed={parsedCustomOnchain}
|
||||
expectedMode="onchain"
|
||||
/>
|
||||
<CustomWalletInput
|
||||
id="campaign-wallet-sp"
|
||||
label={t('campaignsCreate.silentPaymentCode')}
|
||||
placeholder={t('campaignsCreate.silentPaymentCodePlaceholder')}
|
||||
value={customSp}
|
||||
onChange={onCustomSpChange}
|
||||
parsed={parsedCustomSp}
|
||||
expectedMode="sp"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{walletSource === 'mine' ? (
|
||||
<>
|
||||
{/* Identity + balance card. Tapping the pencil swaps into the
|
||||
custom-wallet inputs without changing wizard step. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWalletSourceChange('custom')}
|
||||
aria-label={t('campaignsCreate.walletEditAria')}
|
||||
className="group w-full rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-card/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{myWalletLabel}</p>
|
||||
{balanceLoading ? (
|
||||
<Skeleton className="mt-1 h-4 w-24" />
|
||||
) : btcPrice ? (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
<span className="font-medium text-foreground">
|
||||
{satsToUSD(totalBalance, btcPrice)}
|
||||
</span>
|
||||
<span className="mx-1.5 opacity-60">·</span>
|
||||
<span>{formatBTC(totalBalance)} BTC</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatBTC(totalBalance)} BTC
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Pencil className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* "Use a custom wallet" sub-link — quieter affordance for the
|
||||
same swap, since the pencil alone is easy to miss. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWalletSourceChange('custom')}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
{t('campaignsCreate.walletUseCustom')}
|
||||
</button>
|
||||
|
||||
{/* Accept-mode segmented picker. Default 'all' (HD + SP); the
|
||||
non-SP options are only relevant if SP is unsupported. */}
|
||||
<AcceptModePicker
|
||||
value={mineAccept}
|
||||
onChange={onMineAcceptChange}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Header strip — name the current mode and offer the swap
|
||||
back to the user's wallet. Mirrors the pencil card so
|
||||
both directions of the toggle feel symmetric. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Wallet className="size-3.5" />
|
||||
</span>
|
||||
{t('campaignsCreate.walletCustom')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWalletSourceChange('mine')}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3 rtl:rotate-180" />
|
||||
{t('campaignsCreate.walletUseMine')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{walletSource === 'custom' && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<CustomWalletInput
|
||||
id="campaign-wallet-onchain"
|
||||
label={t('campaignsCreate.bitcoinAddress')}
|
||||
@@ -1305,12 +1353,83 @@ function WalletPicker({
|
||||
parsed={parsedCustomSp}
|
||||
expectedMode="sp"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented "Accept" picker for the HD-wallet branch. Three pill
|
||||
* buttons (All / Public / Private) with a one-line caption beneath
|
||||
* that explains the current selection. Public is always available;
|
||||
* the All and Private buttons disable when SP isn't supported
|
||||
* (extension / bunker logins).
|
||||
*/
|
||||
function AcceptModePicker({
|
||||
value,
|
||||
onChange,
|
||||
silentPaymentSupported,
|
||||
}: {
|
||||
value: 'all' | 'public' | 'private';
|
||||
onChange: (next: 'all' | 'public' | 'private') => void;
|
||||
silentPaymentSupported: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const caption = {
|
||||
all: t('campaignsCreate.acceptAllHint'),
|
||||
public: t('campaignsCreate.acceptPublicHint'),
|
||||
private: t('campaignsCreate.acceptPrivateHint'),
|
||||
}[value];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value}
|
||||
// Radix ToggleGroup emits '' when the user toggles off the
|
||||
// selected item. Required campaigns can never be in "no
|
||||
// mode" state — coerce empty back to the previous value.
|
||||
onValueChange={(next) => {
|
||||
if (!next) return;
|
||||
onChange(next as 'all' | 'public' | 'private');
|
||||
}}
|
||||
variant="outline"
|
||||
className="grid w-full grid-cols-3 gap-1.5"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="all"
|
||||
disabled={!silentPaymentSupported}
|
||||
className="h-auto justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-medium"
|
||||
aria-label={t('campaignsCreate.acceptAll')}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t('campaignsCreate.acceptAllShort')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="public"
|
||||
className="h-auto justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-medium"
|
||||
aria-label={t('campaignsCreate.acceptPublic')}
|
||||
>
|
||||
<Bitcoin className="h-3.5 w-3.5" />
|
||||
{t('campaignsCreate.acceptPublicShort')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="private"
|
||||
disabled={!silentPaymentSupported}
|
||||
className="h-auto justify-center gap-1.5 rounded-full px-3 py-2 text-xs font-medium"
|
||||
aria-label={t('campaignsCreate.acceptPrivate')}
|
||||
>
|
||||
<Radar className="h-3.5 w-3.5" />
|
||||
{t('campaignsCreate.acceptPrivateShort')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<p className="text-xs text-muted-foreground">{caption}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single labeled custom-wallet input. The inline error fires only when
|
||||
* a non-empty value either fails to parse OR parses to a mode that
|
||||
|
||||
Reference in New Issue
Block a user