feat(frontend): move ZapPlanner button to Wallet and move wallet mobile actions to overflow menu (#2200)

* feat(frontend): improve wallet page balance layout

Center the wallet balance area and tune spacing for a cleaner visual rhythm.
Move secondary actions to the header as ghost buttons and use a vertical more icon.

Made-with: Cursor

* feat(frontend): refine wallet header actions on mobile

Move wallet secondary actions into the mobile overflow menu and add Recurring.
Also remove the ZapPlanner card from Home to avoid duplicate entry points.

Made-with: Cursor

* refactor(frontend): separate wallet actions, add ProDropdownMenuItem and AlertAction

- Extract wallet navigation (Swap, Recurring, Buy) into dedicated WalletActionsMenu component
- Revert TransactionsListMenu to single-purpose (export transactions only)
- Move CSV export logic to shared transactions-utils
- Add ProDropdownMenuItem for reusable pro-gated dropdown items with consistent Pro badge
- Add AlertAction component to alert system for proper action button placement
- Use controlled mode in UpgradeDialog to avoid DialogTrigger data-slot conflicts
- Replace inline upgrade gating in Channels (Set Node Alias) and Settings (themes)
- Theme select now opens UpgradeDialog instead of disabling paid items
- Use AlertAction in SubwalletList for upgrade prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(frontend): consolidate export into WalletActionsMenu

Show wallet actions (Swap, Recurring, Buy) only on mobile,
Export Transactions on all breakpoints — removes the separate
TransactionsListMenu from the wallet page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(frontend): sanitize CSV export and fix subwallet upgrade copy

Prevent CSV formula injection by prepending a single quote to values
starting with =, +, -, or @. Fix grammar in sub-wallet upgrade prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(frontend): center fiat amount skeleton on wallet page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saunter 2026-04-15 16:18:41 +02:00 committed by GitHub
parent ac88c8d573
commit 46d3a8942a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 298 additions and 214 deletions

View file

@ -1,96 +1,14 @@
import { DownloadIcon, EllipsisVerticalIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "src/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu";
import { UpgradeDialog } from "src/components/UpgradeDialog";
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { ListTransactionsResponse, Transaction } from "src/types";
import { request } from "src/utils/request";
const convertToCSV = (transactions: Transaction[]) => {
if (!transactions.length) {
return "";
}
// Get headers from all transactions
const headers = Object.keys(transactions[0]);
const csvHeaders = headers.join(",");
// Convert each transaction to CSV row
const csvRows = transactions.map((tx) => {
return headers
.map((header) => {
const value = tx[header as keyof typeof tx];
if (value === undefined || value === null) {
return "";
}
const stringValue =
typeof value === "object" ? JSON.stringify(value) : String(value);
// based on https://stackoverflow.com/a/68146412
return `"${stringValue.replaceAll('"', '""')}"`; // escape double quotes
})
.join(",");
});
return [csvHeaders, ...csvRows].join("\n");
};
const handleExportTransactions = async (appId?: number) => {
try {
// Fetch all transactions by paginating through all pages
let allTransactions: Transaction[] = [];
let offset = 0;
while (true) {
let url = `/api/transactions?limit=${LIST_TRANSACTIONS_LIMIT}&offset=${offset}`;
if (appId) {
url += `&appId=${appId}`;
}
const data = await request<ListTransactionsResponse>(url);
if (!data) {
throw new Error("no list transactions response");
}
allTransactions = [...allTransactions, ...data.transactions];
if (data.transactions.length < LIST_TRANSACTIONS_LIMIT) {
break;
}
offset += LIST_TRANSACTIONS_LIMIT;
}
// Convert to CSV and create download
const csvString = convertToCSV(allTransactions);
const blob = new Blob([csvString], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename = appId
? `transactions_app_${appId}.csv`
: `transactions_all.csv`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast("Transactions saved to your downloads folder");
} catch (error) {
console.error("Error downloading transactions:", error);
toast.error("Failed to export transactions");
}
};
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import { handleExportTransactions } from "./transactions-utils";
export const TransactionsListMenu = ({ appId }: { appId?: number }) => {
const { data: albyMe } = useAlbyMe();
return (
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
@ -99,26 +17,10 @@ export const TransactionsListMenu = ({ appId }: { appId?: number }) => {
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
{!albyMe?.subscription.plan_code ? (
<UpgradeDialog>
<div className="cursor-pointer">
<DropdownMenuItem className="w-full pointer-events-none">
<div className="w-full flex items-center">
<DownloadIcon className="h-4 w-4 mr-2" />
Export Transactions
</div>
</DropdownMenuItem>
</div>
</UpgradeDialog>
) : (
<DropdownMenuItem
className="flex flex-row items-center gap-2 cursor-pointer"
onClick={() => handleExportTransactions(appId)}
>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</DropdownMenuItem>
)}
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -3,27 +3,77 @@ import {
MailIcon,
RefreshCwIcon,
SparklesIcon,
StarsIcon,
UsersIcon,
ZapIcon,
} from "lucide-react";
import { ReactNode } from "react";
import { ReactNode, useState } from "react";
import { Badge } from "src/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "src/components/ui/dialog";
import { DropdownMenuItem } from "src/components/ui/dropdown-menu";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useInfo } from "src/hooks/useInfo";
import { ExternalLinkButton } from "./ui/custom/external-link-button";
interface UpgradeDialogProps {
children: ReactNode;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const UpgradeDialog = ({ children }: UpgradeDialogProps) => {
interface ProDropdownMenuItemProps {
children: ReactNode;
onClick?: () => void;
}
export function ProDropdownMenuItem({
children,
onClick,
}: ProDropdownMenuItemProps) {
const { data: albyMe } = useAlbyMe();
if (!albyMe?.subscription.plan_code) {
return (
<UpgradeDialog>
<div className="cursor-pointer">
<DropdownMenuItem className="w-full pointer-events-none">
{children}
<Badge variant="outline">
<StarsIcon />
Pro
</Badge>
</DropdownMenuItem>
</div>
</UpgradeDialog>
);
}
return (
<DropdownMenuItem className="cursor-pointer" onClick={onClick}>
{children}
<Badge variant="outline">
<StarsIcon />
Pro
</Badge>
</DropdownMenuItem>
);
}
export const UpgradeDialog = ({
children,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: UpgradeDialogProps) => {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const { data: albyMe } = useAlbyMe();
const { data: info } = useInfo();
@ -37,8 +87,12 @@ export const UpgradeDialog = ({ children }: UpgradeDialogProps) => {
}
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<Dialog open={open} onOpenChange={setOpen}>
{children && (
<span onClick={() => setOpen(true)} className="cursor-pointer">
{children}
</span>
)}
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex flex-row gap-2 items-center">

View file

@ -0,0 +1,70 @@
import {
ArrowDownUpIcon,
CalendarSyncIcon,
CreditCardIcon,
DownloadIcon,
EllipsisVerticalIcon,
} from "lucide-react";
import { Link } from "react-router";
import ExternalLink from "src/components/ExternalLink";
import { Button } from "src/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu";
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import { handleExportTransactions } from "./transactions-utils";
export function WalletActionsMenu({
hasChannelManagement,
}: {
hasChannelManagement: boolean;
}) {
return (
<DropdownMenu>
<Button asChild size="icon" variant="ghost">
<DropdownMenuTrigger>
<EllipsisVerticalIcon className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<div className="sm:hidden">
{hasChannelManagement && (
<DropdownMenuItem asChild>
<Link to="/wallet/swap" className="w-full cursor-pointer">
<ArrowDownUpIcon className="h-4 w-4" />
Swap
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link
to="/internal-apps/zapplanner"
className="w-full cursor-pointer"
>
<CalendarSyncIcon className="h-4 w-4" />
Recurring
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ExternalLink
to="https://www.getalby.com/topup"
className="w-full cursor-pointer"
>
<CreditCardIcon className="h-4 w-4" />
Buy
</ExternalLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
</div>
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
<DownloadIcon className="h-4 w-4" />
Export Transactions
</ProDropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,82 @@
import { toast } from "sonner";
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
import { ListTransactionsResponse, Transaction } from "src/types";
import { request } from "src/utils/request";
export const convertToCSV = (transactions: Transaction[]) => {
if (!transactions.length) {
return "";
}
// Get headers from all transactions
const headers = Object.keys(transactions[0]);
const csvHeaders = headers.join(",");
// Convert each transaction to CSV row
const csvRows = transactions.map((tx) => {
return headers
.map((header) => {
const value = tx[header as keyof typeof tx];
if (value === undefined || value === null) {
return "";
}
const stringValue =
typeof value === "object" ? JSON.stringify(value) : String(value);
const safeValue = /^[\t\r ]*[=+\-@]/.test(stringValue)
? `'${stringValue}`
: stringValue;
// based on https://stackoverflow.com/a/68146412
return `"${safeValue.replaceAll('"', '""')}"`; // escape double quotes
})
.join(",");
});
return [csvHeaders, ...csvRows].join("\n");
};
export const handleExportTransactions = async (appId?: number) => {
try {
// Fetch all transactions by paginating through all pages
let allTransactions: Transaction[] = [];
let offset = 0;
while (true) {
let url = `/api/transactions?limit=${LIST_TRANSACTIONS_LIMIT}&offset=${offset}`;
if (appId) {
url += `&appId=${appId}`;
}
const data = await request<ListTransactionsResponse>(url);
if (!data) {
throw new Error("no list transactions response");
}
allTransactions = [...allTransactions, ...data.transactions];
if (data.transactions.length < LIST_TRANSACTIONS_LIMIT) {
break;
}
offset += LIST_TRANSACTIONS_LIMIT;
}
// Convert to CSV and create download
const csvString = convertToCSV(allTransactions);
const blob = new Blob([csvString], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename = appId
? `transactions_app_${appId}.csv`
: `transactions_all.csv`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast("Transactions saved to your downloads folder");
} catch (error) {
console.error("Error downloading transactions:", error);
toast.error("Failed to export transactions");
}
};

View file

@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "src/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current has-[[data-slot=alert-action]]:grid-cols-[0_1fr_auto] has-[>svg]:has-[[data-slot=alert-action]]:grid-cols-[calc(var(--spacing)*4)_1fr_auto]",
{
variants: {
variant: {
@ -65,4 +65,14 @@ function AlertDescription({
);
}
export { Alert, AlertTitle, AlertDescription };
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("col-start-3 row-start-1 row-span-2 self-start", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };

View file

@ -4,7 +4,6 @@ import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import { AlbyHead } from "src/components/images/AlbyHead";
import Loading from "src/components/Loading";
import { Badge } from "src/components/ui/badge";
import { Button } from "src/components/ui/button";
import {
Card,
@ -20,7 +19,6 @@ import OnboardingChecklist from "src/screens/wallet/OnboardingChecklist";
import React from "react";
import albyGo from "src/assets/suggested-apps/alby-go.png";
import zapplanner from "src/assets/suggested-apps/zapplanner.png";
import { AppOfTheDayWidget } from "src/components/home/widgets/AppOfTheDayWidget";
import { BlockHeightWidget } from "src/components/home/widgets/BlockHeightWidget";
import { ForwardsWidget } from "src/components/home/widgets/ForwardsWidget";
@ -168,34 +166,6 @@ function Home() {
<div className="grid gap-3">
<LightningMessageboardWidget />
<Link to="/internal-apps/zapplanner">
<Card>
<CardHeader>
<div className="flex flex-row items-center">
<div className="shrink-0">
<img
src={zapplanner}
className="w-12 h-12 rounded-xl border"
/>
</div>
<div>
<CardTitle>
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4 flex gap-2">
ZapPlanner <Badge>NEW</Badge>
</div>
</CardTitle>
<CardDescription className="ml-4">
Schedule automatic recurring lightning payments.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="text-right">
<Button variant="outline">Open</Button>
</CardContent>
</Card>
</Link>
<Card>
<CardHeader>
<div className="flex justify-between items-center">

View file

@ -10,7 +10,6 @@ import {
InfoIcon,
LinkIcon,
Settings2Icon,
SparklesIcon,
UnplugIcon,
ZapIcon,
} from "lucide-react";
@ -57,9 +56,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "src/components/ui/tooltip.tsx";
import { UpgradeDialog } from "src/components/UpgradeDialog";
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
import { ONCHAIN_DUST_SATS } from "src/constants.ts";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useBalances } from "src/hooks/useBalances.ts";
import { useChannels } from "src/hooks/useChannels";
import { useInfo } from "src/hooks/useInfo";
@ -78,7 +76,6 @@ import { request } from "src/utils/request";
export default function Channels() {
useSyncWallet();
const { data: albyMe } = useAlbyMe();
const { data: channels } = useChannels();
const { data: nodeConnectionInfo } = useNodeConnectionInfo();
const { data: info, hasChannelManagement } = useInfo();
@ -270,28 +267,13 @@ export default function Channels() {
Sign Message
</Link>
</DropdownMenuItem>
{info?.backendType === "LDK" &&
(!albyMe?.subscription.plan_code ? (
<UpgradeDialog>
<div className="cursor-pointer">
<DropdownMenuItem className="w-full pointer-events-none">
<Link
className="w-full flex items-center"
to="/wallet/node-alias"
>
<SparklesIcon className="size-4 mr-2" /> Set
Node Alias
</Link>
</DropdownMenuItem>
</div>
</UpgradeDialog>
) : (
<DropdownMenuItem className="w-full">
<Link className="w-full" to="/wallet/node-alias">
Set Node Alias
</Link>
</DropdownMenuItem>
))}
{info?.backendType === "LDK" && (
<ProDropdownMenuItem
onClick={() => navigate("/wallet/node-alias")}
>
Set Node Alias
</ProDropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,7 +1,9 @@
import { StarsIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import Loading from "src/components/Loading";
import SettingsHeader from "src/components/SettingsHeader";
import { StarsIcon } from "lucide-react";
import { UpgradeDialog } from "src/components/UpgradeDialog";
import { Badge } from "src/components/ui/badge";
import { Label } from "src/components/ui/label";
import {
@ -24,7 +26,6 @@ import {
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useCurrencies } from "src/hooks/useCurrencies";
import { useInfo } from "src/hooks/useInfo";
import { cn } from "src/lib/utils";
import { handleRequestError } from "src/utils/handleRequestError";
import { request } from "src/utils/request";
@ -32,6 +33,7 @@ function Settings() {
const { data: albyMe } = useAlbyMe();
const { theme, darkMode, setTheme, setDarkMode } = useTheme();
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies();
const [showUpgradeDialog, setShowUpgradeDialog] = React.useState(false);
const { data: info, mutate: reloadInfo } = useInfo();
@ -96,6 +98,10 @@ function Settings() {
<Select
value={theme}
onValueChange={(value) => {
if (paidThemes.includes(value) && !hasPlan) {
setShowUpgradeDialog(true);
return;
}
setTheme(value as Theme);
toast("Theme updated.");
}}
@ -106,23 +112,11 @@ function Settings() {
<SelectContent>
{Themes.map((theme) => {
const isPaidTheme = paidThemes.includes(theme);
const isDisabled = isPaidTheme && !hasPlan;
return (
<SelectItem
key={theme}
value={theme}
disabled={isDisabled}
>
<SelectItem key={theme} value={theme}>
<div className="flex items-center justify-between gap-2 w-full">
<span
className={cn(
"capitalize",
isDisabled && "text-muted-foreground"
)}
>
{theme}
</span>
<span className="capitalize">{theme}</span>
{isPaidTheme && (
<Badge variant="outline">
<StarsIcon />
@ -135,6 +129,10 @@ function Settings() {
})}
</SelectContent>
</Select>
<UpgradeDialog
open={showUpgradeDialog}
onOpenChange={setShowUpgradeDialog}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="appearance">Appearance</Label>

View file

@ -15,7 +15,12 @@ import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import Loading from "src/components/Loading";
import ResponsiveButton from "src/components/ResponsiveButton";
import ResponsiveLinkButton from "src/components/ResponsiveLinkButton";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "src/components/ui/alert";
import { Button } from "src/components/ui/button";
import {
Card,
@ -108,24 +113,21 @@ export function SubwalletList() {
{!albyMe?.subscription.plan_code &&
subwalletAppsData.totalCount >= MAX_FREE_SUBWALLETS && (
<>
<Alert>
<InfoIcon />
<AlertTitle>Need more Sub-wallets?</AlertTitle>
<AlertDescription className="flex flex-row gap-3">
<p className="grow">
Upgrade your subscription plan to Pro unlock unlimited number
of Sub-wallets.
</p>
<UpgradeDialog>
<Button>
<SparklesIcon />
Upgrade
</Button>
</UpgradeDialog>
</AlertDescription>
</Alert>
</>
<Alert>
<InfoIcon />
<AlertTitle>Need more Sub-wallets?</AlertTitle>
<AlertDescription>
Upgrade to Pro for unlimited sub-wallets.
</AlertDescription>
<AlertAction>
<UpgradeDialog>
<Button size="sm">
<SparklesIcon />
Upgrade
</Button>
</UpgradeDialog>
</AlertAction>
</Alert>
)}
{!isSufficientlyBacked && (

View file

@ -4,6 +4,7 @@ import {
ArrowDownIcon,
ArrowDownUpIcon,
ArrowUpIcon,
CalendarSyncIcon,
CreditCardIcon,
ExternalLinkIcon,
LightbulbIcon,
@ -15,7 +16,7 @@ import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import Loading from "src/components/Loading";
import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert";
import TransactionsList from "src/components/TransactionsList";
import { TransactionsListMenu } from "src/components/TransactionsListMenu";
import { WalletActionsMenu } from "src/components/WalletActionsMenu";
import {
Alert,
AlertDescription,
@ -45,23 +46,36 @@ function Wallet() {
description=""
contentRight={
<div className="flex items-center gap-1 sm:gap-2">
{hasChannelManagement && (
<LinkButton
to="/wallet/swap"
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
>
<ArrowDownUpIcon />
Swap
</LinkButton>
)}
<LinkButton
to="/internal-apps/zapplanner"
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
>
<CalendarSyncIcon />
Recurring
</LinkButton>
<ExternalLinkButton
to="https://www.getalby.com/topup"
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
>
<CreditCardIcon />
<span className="sr-only sm:not-sr-only sm:inline">
Buy Bitcoin
</span>
Buy
</ExternalLinkButton>
{hasChannelManagement && (
<LinkButton to="/wallet/swap" variant="ghost" size="sm">
<ArrowDownUpIcon />
<span className="sr-only sm:not-sr-only sm:inline">Swap</span>
</LinkButton>
)}
<TransactionsListMenu />
<WalletActionsMenu hasChannelManagement={!!hasChannelManagement} />
</div>
}
/>