From 54df991fd134c0aa700aafe91d1c82c2cbddb3c6 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 3 Jul 2026 01:33:02 +0530 Subject: [PATCH 1/3] feat: wallet card block with morphing account switcher and search Account switcher and search icon each morph open from their trigger via shared layout; DiceBear glass avatars, a rolling balance with a live change pill, and Send/Deposit/Swap/Buy actions. --- .../motion/wallet-card/account-avatar.tsx | 21 ++ .../motion/wallet-card/account-switcher.tsx | 193 +++++++++++++++++ components/motion/wallet-card/actions.tsx | 59 ++++++ .../motion/wallet-card/balance-delta.tsx | 63 ++++++ components/motion/wallet-card/constants.ts | 18 ++ components/motion/wallet-card/index.tsx | 114 ++++++++++ components/motion/wallet-card/search-bar.tsx | 198 ++++++++++++++++++ components/motion/wallet-card/types.ts | 28 +++ components/motion/wallet-card/use-dismiss.ts | 28 +++ components/motion/wallet-card/utils.ts | 9 + .../previews/blocks/wallet-card.preview.tsx | 34 +++ components/previews/index.tsx | 3 + lib/registry.ts | 14 ++ 13 files changed, 782 insertions(+) create mode 100644 components/motion/wallet-card/account-avatar.tsx create mode 100644 components/motion/wallet-card/account-switcher.tsx create mode 100644 components/motion/wallet-card/actions.tsx create mode 100644 components/motion/wallet-card/balance-delta.tsx create mode 100644 components/motion/wallet-card/constants.ts create mode 100644 components/motion/wallet-card/index.tsx create mode 100644 components/motion/wallet-card/search-bar.tsx create mode 100644 components/motion/wallet-card/types.ts create mode 100644 components/motion/wallet-card/use-dismiss.ts create mode 100644 components/motion/wallet-card/utils.ts create mode 100644 components/previews/blocks/wallet-card.preview.tsx diff --git a/components/motion/wallet-card/account-avatar.tsx b/components/motion/wallet-card/account-avatar.tsx new file mode 100644 index 0000000..9e79ad9 --- /dev/null +++ b/components/motion/wallet-card/account-avatar.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/lib/utils"; +import type { WalletAccount } from "./types"; +import { diceBearGlassUrl } from "./utils"; + +export function AccountAvatar({ + account, + className, +}: { + account: WalletAccount; + className?: string; +}) { + if (account.avatar) return <>{account.avatar}; + return ( + // biome-ignore lint/performance/noImgElement: remote DiceBear SVG, no next/image benefit + {account.name} + ); +} diff --git a/components/motion/wallet-card/account-switcher.tsx b/components/motion/wallet-card/account-switcher.tsx new file mode 100644 index 0000000..120d957 --- /dev/null +++ b/components/motion/wallet-card/account-switcher.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { AccountAvatar } from "./account-avatar"; +import { HEAD, ITEM, LIST, MORPH } from "./constants"; +import type { WalletAccount } from "./types"; +import { useDismiss } from "./use-dismiss"; +import { truncateAddress } from "./utils"; + +/** + * Account switcher whose trigger morphs into a panel that grows rightward to + * full width (covering the header icons) and downward at the same time, via a + * shared layoutId. Self-contained — not the generic MorphSelect. + */ +export function AccountSwitcher({ + accounts, + activeAccount, + onSelect, +}: { + accounts: WalletAccount[]; + activeAccount: WalletAccount | undefined; + onSelect: (id: string) => void; +}) { + const reduce = useReducedMotion() ?? false; + const layoutId = `${useId()}-account`; + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + // Hover only arms after the morph settles — otherwise the panel expands under + // the cursor and flashes a phantom hover bg on whatever item it lands on. + const [armed, setArmed] = useState(false); + const close = useCallback(() => setOpen(false), []); + + useDismiss(open, close, rootRef); + + useEffect(() => { + if (!open) { + setArmed(false); + return; + } + const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 280); + return () => window.clearTimeout(t); + }, [open, reduce]); + + const morph = reduce ? { duration: 0 } : MORPH; + + return ( + // static (not relative) so the absolute trigger + panel anchor to the + // header row and can span its full width, covering the icons. +
+ {/* in-flow sizer: reserves the widest possible trigger footprint (every + account name stacked in one grid cell) so the header row width never + changes — neither when the trigger leaves the flow nor when a shorter + account name is selected. */} +
+ + + {accounts.map((account) => ( + + {account.name} + + ))} + + +
+ + + {open ? null : ( + setOpen(true)} + transition={morph} + style={{ borderRadius: 16 }} + className={cn( + HEAD, + // shifted left by the padding so the avatar sits flush with the + // card content edge (aligned with the balance + buttons below). + "absolute top-0 -left-2 z-20 max-w-full outline-none transition-colors hover:bg-muted/60", + )} + > + {activeAccount ? ( + <> + + + {activeAccount.name} + + + + + + ) : null} + + )} + + + + {open ? ( + + + + + {accounts.map((account) => { + const selected = account.id === activeAccount?.id; + return ( + + + + ); + })} + + + ) : null} + +
+ ); +} diff --git a/components/motion/wallet-card/actions.tsx b/components/motion/wallet-card/actions.tsx new file mode 100644 index 0000000..e4d8dc0 --- /dev/null +++ b/components/motion/wallet-card/actions.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { ArrowDownToLine, ArrowUp, CreditCard, Repeat } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import type { ComponentType } from "react"; +import { SPRING_PRESS } from "@/lib/ease"; + +type WalletAction = { + key: string; + label: string; + icon: ComponentType<{ className?: string }>; + onClick?: () => void; +}; + +/** + * Row of primary wallet actions rendered icon-over-label, with a spring press. + */ +export function WalletActions({ + onSend, + onDeposit, + onSwap, + onBuy, +}: { + onSend?: () => void; + onDeposit?: () => void; + onSwap?: () => void; + onBuy?: () => void; +}) { + const reduce = useReducedMotion(); + + const actions: WalletAction[] = [ + { key: "send", label: "Send", icon: ArrowUp, onClick: onSend }, + { key: "deposit", label: "Deposit", icon: ArrowDownToLine, onClick: onDeposit }, + { key: "swap", label: "Swap", icon: Repeat, onClick: onSwap }, + { key: "buy", label: "Buy", icon: CreditCard, onClick: onBuy }, + ]; + + return ( +
+ {actions.map(({ key, label, icon: Icon, onClick }) => ( + + + + + + {label} + + + ))} +
+ ); +} diff --git a/components/motion/wallet-card/balance-delta.tsx b/components/motion/wallet-card/balance-delta.tsx new file mode 100644 index 0000000..32f9070 --- /dev/null +++ b/components/motion/wallet-card/balance-delta.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { TrendingDown, TrendingUp } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +/** + * A transient change indicator for the balance: a tinted pill with a trend + * arrow that pops in whenever the balance moves and persists until it moves + * again. + */ +export function BalanceDelta({ balance }: { balance: number }) { + const reduce = useReducedMotion(); + const prevRef = useRef(balance); + const [delta, setDelta] = useState<{ id: number; amount: number } | null>( + null, + ); + + // Persist the last change until the balance moves again — don't auto-hide. + useEffect(() => { + const diff = balance - prevRef.current; + prevRef.current = balance; + if (diff === 0) return; + setDelta({ id: Date.now(), amount: diff }); + }, [balance]); + + const up = (delta?.amount ?? 0) > 0; + + return ( +
+ + {delta ? ( + + {up ? ( + + ) : ( + + )} + {up ? "+" : "-"}$ + {Math.abs(delta.amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : null} + +
+ ); +} diff --git a/components/motion/wallet-card/constants.ts b/components/motion/wallet-card/constants.ts new file mode 100644 index 0000000..b2f7037 --- /dev/null +++ b/components/motion/wallet-card/constants.ts @@ -0,0 +1,18 @@ +import type { Transition, Variants } from "motion/react"; + +// Trigger box grows into the full-width panel and back — one shared surface. +export const MORPH: Transition = { type: "spring", duration: 0.5, bounce: 0.22 }; + +export const LIST: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.12 } }, +}; + +export const ITEM: Variants = { + hidden: { opacity: 0, y: -6, filter: "blur(3px)" }, + show: { opacity: 1, y: 0, filter: "blur(0px)" }, +}; + +// Shared padding for the account trigger + panel header so the avatar/name stay +// put as the box morphs — the panel reads as the trigger itself growing open. +export const HEAD = "flex items-center gap-2 px-2 py-1.5 text-left"; diff --git a/components/motion/wallet-card/index.tsx b/components/motion/wallet-card/index.tsx new file mode 100644 index 0000000..64e2b25 --- /dev/null +++ b/components/motion/wallet-card/index.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Bell } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/motion/button"; +import { NumberTicker } from "@/components/motion/number-ticker"; +import { cn } from "@/lib/utils"; +import { AccountSwitcher } from "./account-switcher"; +import { WalletActions } from "./actions"; +import { BalanceDelta } from "./balance-delta"; +import { SearchBar } from "./search-bar"; +import type { WalletCardProps } from "./types"; + +export type { WalletAccount, WalletCardProps } from "./types"; + +/** + * Composed wallet overview card: an account switcher whose trigger morphs open + * into a full-width panel, a search icon that morphs into a search bar, a + * rolling balance with a transient change indicator, and Send / Deposit + * actions. Actions and search are plain callbacks — the resulting flow is left + * to the consumer. + */ +export function WalletCard({ + accounts, + accountId, + defaultAccountId, + onAccountChange, + balance, + balancePrefix = "$", + onSend, + onDeposit, + onSwap, + onBuy, + searchPlaceholder, + searchRecent, + onSearchChange, + onSearchSubmit, + onNotifications, + className, +}: WalletCardProps) { + const accountControlled = accountId !== undefined; + const [internalAccountId, setInternalAccountId] = useState( + defaultAccountId ?? accounts[0]?.id, + ); + const activeAccountId = accountControlled ? accountId : internalAccountId; + const activeAccount = + accounts.find((a) => a.id === activeAccountId) ?? accounts[0]; + + const handleAccountChange = (id: string) => { + if (!accountControlled) setInternalAccountId(id); + onAccountChange?.(id); + }; + + return ( +
+ {/* relative anchor so the switcher + search panels span the whole row */} +
+ + +
+ + +
+
+ +
+

Balance

+ + (n / 100).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + } + startOnView={false} + className="text-3xl font-semibold text-foreground" + /> + +
+ +
+ +
+
+ ); +} diff --git a/components/motion/wallet-card/search-bar.tsx b/components/motion/wallet-card/search-bar.tsx new file mode 100644 index 0000000..99cf2ba --- /dev/null +++ b/components/motion/wallet-card/search-bar.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { History, Search } from "lucide-react"; +import { + AnimatePresence, + motion, + type Transition, + useReducedMotion, +} from "motion/react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; +import { ITEM, LIST, MORPH } from "./constants"; +import { useDismiss } from "./use-dismiss"; + +/** + * Search icon that morphs into a full-width search bar via a shared layoutId, + * growing leftward across the header row. The recent-searches results render as + * a SEPARATE dropdown below the bar — not part of the morphing element — so + * filtering as you type resizes only the dropdown and never re-fires the morph + * (which would scale-distort the input text). The in-flow slot keeps its width + * whether open or closed so the header row (and card) never shifts. + */ +export function SearchBar({ + placeholder = "Search", + recent = [], + onChange, + onSubmit, +}: { + placeholder?: string; + recent?: string[]; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; +}) { + const reduce = useReducedMotion() ?? false; + const layoutId = `${useId()}-search`; + const rootRef = useRef(null); + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + // Hover arms after the dropdown reveals so it doesn't flash a phantom hover. + const [armed, setArmed] = useState(false); + const close = useCallback(() => setOpen(false), []); + + useDismiss(open, close, rootRef); + + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open) { + setArmed(false); + return; + } + const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 260); + return () => window.clearTimeout(t); + }, [open, reduce]); + + // The box keeps the bouncy morph (same feel as the account switcher)... + const morph: Transition = reduce ? { duration: 0 } : MORPH; + // ...but the leading icon + input travel across the row, so their own layout + // uses a critically-damped spring — they glide to place without inheriting the + // box's overshoot (which read as the icon jittering right-then-left). + const glide: Transition = reduce + ? { duration: 0 } + : { type: "spring", duration: 0.5, bounce: 0 }; + + const query = value.trim().toLowerCase(); + const filtered = query + ? recent.filter((r) => r.toLowerCase().includes(query)) + : recent; + + const submit = (next: string) => { + onSubmit?.(next); + setOpen(false); + }; + + return ( + // static so the open bar + dropdown anchor to the header row (spanning its + // width), while the slot below reserves the icon's footprint. +
+ {/* reserve the icon's width while open (the icon has left the flow) */} + {open ?
: null} + + + {open ? null : ( + setOpen(true)} + transition={morph} + style={{ borderRadius: 12 }} + className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground" + > + + + )} + + + {/* one connected panel: input + results grow out of the trigger. The text + nodes use layout="position" so when the panel resizes (filtering) they + reposition without scaling — no re-morph distortion, still connected. */} + + {open ? ( + +
+ + + + { + setValue(e.target.value); + onChange?.(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) submit(value.trim()); + if (e.key === "Escape") close(); + }} + placeholder={placeholder} + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ +
+ + {filtered.length > 0 ? ( + + {filtered.map((term) => ( + + + + ))} + + ) : ( + + +

+ {query ? "No matches" : "No recent searches"} +

+
+ )} + + ) : null} + +
+ ); +} diff --git a/components/motion/wallet-card/types.ts b/components/motion/wallet-card/types.ts new file mode 100644 index 0000000..2f11029 --- /dev/null +++ b/components/motion/wallet-card/types.ts @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +export type WalletAccount = { + id: string; + name: string; + address: string; + avatar?: ReactNode; +}; + +export interface WalletCardProps { + accounts: WalletAccount[]; + accountId?: string; + defaultAccountId?: string; + onAccountChange?: (id: string) => void; + balance: number; + balancePrefix?: string; + onSend?: () => void; + onDeposit?: () => void; + onSwap?: () => void; + onBuy?: () => void; + searchPlaceholder?: string; + /** Recent searches shown in the expanded search panel. */ + searchRecent?: string[]; + onSearchChange?: (value: string) => void; + onSearchSubmit?: (value: string) => void; + onNotifications?: () => void; + className?: string; +} diff --git a/components/motion/wallet-card/use-dismiss.ts b/components/motion/wallet-card/use-dismiss.ts new file mode 100644 index 0000000..f2b1a46 --- /dev/null +++ b/components/motion/wallet-card/use-dismiss.ts @@ -0,0 +1,28 @@ +import { type RefObject, useEffect } from "react"; + +/** + * Close an open overlay on Escape or a pointerdown outside `ref`. `onDismiss` + * must be stable (wrap in useCallback) so the listeners aren't re-bound every + * render while open. + */ +export function useDismiss( + open: boolean, + onDismiss: () => void, + ref: RefObject, +) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onDismiss(); + }; + const onPointer = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onDismiss(); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("pointerdown", onPointer); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("pointerdown", onPointer); + }; + }, [open, onDismiss, ref]); +} diff --git a/components/motion/wallet-card/utils.ts b/components/motion/wallet-card/utils.ts new file mode 100644 index 0000000..e8f24cb --- /dev/null +++ b/components/motion/wallet-card/utils.ts @@ -0,0 +1,9 @@ +export function diceBearGlassUrl(seed: string) { + return `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(seed)}`; +} + +export function truncateAddress(address: string) { + return address.length > 12 + ? `${address.slice(0, 6)}…${address.slice(-4)}` + : address; +} diff --git a/components/previews/blocks/wallet-card.preview.tsx b/components/previews/blocks/wallet-card.preview.tsx new file mode 100644 index 0000000..f304745 --- /dev/null +++ b/components/previews/blocks/wallet-card.preview.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useState } from "react"; +import { WalletCard } from "@/components/motion/wallet-card"; +import { Button } from "@/components/motion/button"; + +const ACCOUNTS = [ + { id: "main", name: "Main Wallet", address: "0x8f3Cb1a29e4D7c6F1B2a3E9d0C4b5A6f7D8e9C0b" }, + { id: "trading", name: "Trading", address: "0x1a2B3c4D5e6F7a8B9c0D1e2F3a4B5c6D7e8F9a0B" }, + { id: "cold", name: "Cold Storage", address: "0x9F8e7D6c5B4a3E2d1C0b9A8f7E6d5C4b3A2e1F0d" }, +]; + +const RECENT_SEARCHES = ["vitalik.eth", "0xA0b8…6EB4", "Uniswap", "Send to Trading"]; + +export function WalletCardPreview() { + const [balance, setBalance] = useState(12480.32); + + return ( +
+ + +
+ ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 0ef426e..40e5708 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -39,6 +39,9 @@ export const previews: Record = { (m) => m.PredictionMarketPreview, ), ), + "blocks/wallet-card": dynamic(() => + import("./blocks/wallet-card.preview").then((m) => m.WalletCardPreview), + ), "motion/table": dynamic(() => import("./motion/table.preview").then((m) => m.TablePreview), ), diff --git a/lib/registry.ts b/lib/registry.ts index 24cde35..e872758 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -520,6 +520,20 @@ export const registry: CategoryEntry[] = [ description: "Prediction market trade ticket with buy/sell modes, outcome prices, rolling amount entry, quick add chips and trade states.", file: "components/motion/prediction-market.tsx", }, + { + slug: "wallet-card", + name: "Wallet Card", + description: "Wallet overview card with an account switcher and search that morph open from their triggers, a rolling balance with a live change indicator, and Send / Deposit actions.", + file: "components/motion/wallet-card/index.tsx", + badge: "new", + keywords: [ + "wallet card react", + "web3 wallet component", + "crypto balance component", + "account switcher react", + "chain switcher react", + ], + }, { slug: "otp-input", name: "OTP Input", From c34be04221958530d6d4da037154827f13d7a127 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 3 Jul 2026 12:13:54 +0530 Subject: [PATCH 2/3] feat: default balance change value on wallet card --- components/motion/wallet-card/balance-delta.tsx | 10 ++++++++-- components/motion/wallet-card/index.tsx | 3 ++- components/motion/wallet-card/types.ts | 2 ++ components/previews/blocks/wallet-card.preview.tsx | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/components/motion/wallet-card/balance-delta.tsx b/components/motion/wallet-card/balance-delta.tsx index 32f9070..16148ba 100644 --- a/components/motion/wallet-card/balance-delta.tsx +++ b/components/motion/wallet-card/balance-delta.tsx @@ -11,11 +11,17 @@ import { cn } from "@/lib/utils"; * arrow that pops in whenever the balance moves and persists until it moves * again. */ -export function BalanceDelta({ balance }: { balance: number }) { +export function BalanceDelta({ + balance, + initialChange, +}: { + balance: number; + initialChange?: number; +}) { const reduce = useReducedMotion(); const prevRef = useRef(balance); const [delta, setDelta] = useState<{ id: number; amount: number } | null>( - null, + initialChange ? { id: 0, amount: initialChange } : null, ); // Persist the last change until the balance moves again — don't auto-hide. diff --git a/components/motion/wallet-card/index.tsx b/components/motion/wallet-card/index.tsx index 64e2b25..7186718 100644 --- a/components/motion/wallet-card/index.tsx +++ b/components/motion/wallet-card/index.tsx @@ -27,6 +27,7 @@ export function WalletCard({ onAccountChange, balance, balancePrefix = "$", + defaultChange, onSend, onDeposit, onSwap, @@ -98,7 +99,7 @@ export function WalletCard({ startOnView={false} className="text-3xl font-semibold text-foreground" /> - +
diff --git a/components/motion/wallet-card/types.ts b/components/motion/wallet-card/types.ts index 2f11029..a9f20ba 100644 --- a/components/motion/wallet-card/types.ts +++ b/components/motion/wallet-card/types.ts @@ -14,6 +14,8 @@ export interface WalletCardProps { onAccountChange?: (id: string) => void; balance: number; balancePrefix?: string; + /** Initial balance change shown in the pill before any live change. */ + defaultChange?: number; onSend?: () => void; onDeposit?: () => void; onSwap?: () => void; diff --git a/components/previews/blocks/wallet-card.preview.tsx b/components/previews/blocks/wallet-card.preview.tsx index f304745..c65c1d9 100644 --- a/components/previews/blocks/wallet-card.preview.tsx +++ b/components/previews/blocks/wallet-card.preview.tsx @@ -20,6 +20,7 @@ export function WalletCardPreview() { + ); })} diff --git a/components/motion/wallet-card/copy-button.tsx b/components/motion/wallet-card/copy-button.tsx new file mode 100644 index 0000000..d74c56c --- /dev/null +++ b/components/motion/wallet-card/copy-button.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { ActionSwapIcon } from "@/components/motion/action-swap"; +import { cn } from "@/lib/utils"; + +/** + * Copies `value` to the clipboard and swaps the copy icon for a check via the + * library's ActionSwapIcon. Stops click propagation so it can sit inside a + * selectable row. + */ +export function CopyButton({ + value, + className, +}: { + value: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + } catch { + // clipboard may be unavailable (insecure context) — swap anyway + } + setCopied(true); + window.setTimeout(() => setCopied(false), 1400); + }; + + return ( + + ); +} diff --git a/components/motion/wallet-card/index.tsx b/components/motion/wallet-card/index.tsx index 7186718..d876d0c 100644 --- a/components/motion/wallet-card/index.tsx +++ b/components/motion/wallet-card/index.tsx @@ -1,9 +1,9 @@ "use client"; -import { Bell } from "lucide-react"; +import { Bell, Eye, EyeOff } from "lucide-react"; import { useState } from "react"; +import { ActionSwapText } from "@/components/motion/action-swap"; import { Button } from "@/components/motion/button"; -import { NumberTicker } from "@/components/motion/number-ticker"; import { cn } from "@/lib/utils"; import { AccountSwitcher } from "./account-switcher"; import { WalletActions } from "./actions"; @@ -28,6 +28,7 @@ export function WalletCard({ balance, balancePrefix = "$", defaultChange, + defaultBalanceHidden = false, onSend, onDeposit, onSwap, @@ -36,6 +37,7 @@ export function WalletCard({ searchRecent, onSearchChange, onSearchSubmit, + hasNotifications = false, onNotifications, className, }: WalletCardProps) { @@ -43,6 +45,13 @@ export function WalletCard({ const [internalAccountId, setInternalAccountId] = useState( defaultAccountId ?? accounts[0]?.id, ); + const [balanceHidden, setBalanceHidden] = useState(defaultBalanceHidden); + + const shownBalance = `${balancePrefix}${balance.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + const maskedBalance = "*".repeat(7); const activeAccountId = accountControlled ? accountId : internalAccountId; const activeAccount = accounts.find((a) => a.id === activeAccountId) ?? accounts[0]; @@ -79,27 +88,54 @@ export function WalletCard({ size="icon" onClick={onNotifications} aria-label="Notifications" + className="relative" > + {hasNotifications ? ( + + + + + ) : null}
-

Balance

- - (n / 100).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }) - } - startOnView={false} +
+

Balance

+ +
+ {/* One ActionSwapText swaps the number and the asterisk mask with a + per-letter cascade — same baseline, no overlap or layout shift. */} + - + > + {balanceHidden ? maskedBalance : shownBalance} + + {balanceHidden ? ( +
+ + ***** + +
+ ) : ( + + )}
diff --git a/components/motion/wallet-card/types.ts b/components/motion/wallet-card/types.ts index a9f20ba..cfecf9b 100644 --- a/components/motion/wallet-card/types.ts +++ b/components/motion/wallet-card/types.ts @@ -16,6 +16,8 @@ export interface WalletCardProps { balancePrefix?: string; /** Initial balance change shown in the pill before any live change. */ defaultChange?: number; + /** Start with the balance hidden behind dots. */ + defaultBalanceHidden?: boolean; onSend?: () => void; onDeposit?: () => void; onSwap?: () => void; @@ -25,6 +27,8 @@ export interface WalletCardProps { searchRecent?: string[]; onSearchChange?: (value: string) => void; onSearchSubmit?: (value: string) => void; + /** Show an unread pulse on the notifications bell. */ + hasNotifications?: boolean; onNotifications?: () => void; className?: string; } diff --git a/components/previews/blocks/wallet-card.preview.tsx b/components/previews/blocks/wallet-card.preview.tsx index c65c1d9..df8e94f 100644 --- a/components/previews/blocks/wallet-card.preview.tsx +++ b/components/previews/blocks/wallet-card.preview.tsx @@ -22,6 +22,7 @@ export function WalletCardPreview() { balance={balance} defaultChange={124.5} searchRecent={RECENT_SEARCHES} + hasNotifications />