feat: redesign app details page

- Two-column dashboard layout (Usage/Permissions/Transactions main, About sidebar)
- Merge balance/spent/received and budget into a single Usage card
- Replace permissions pill wall with a plain-language risk-first summary,
  with a Show details toggle for the full scope list
- Tighten card spacing
This commit is contained in:
René Aaron 2026-06-10 21:01:24 +02:00
parent 81b1b2f695
commit 4f270dac83
3 changed files with 390 additions and 248 deletions

View file

@ -13,15 +13,66 @@ import {
} from "src/components/ui/card";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
function hasAppLinks(appStoreApp: AppStoreApp) {
return !!(
appStoreApp.appleLink ||
appStoreApp.playLink ||
appStoreApp.zapStoreLink ||
appStoreApp.chromeLink ||
appStoreApp.firefoxLink ||
appStoreApp.webLink
);
}
export function AppLinks({ appStoreApp }: { appStoreApp: AppStoreApp }) {
if (!hasAppLinks(appStoreApp)) {
return null;
}
return (
<div className="flex flex-row flex-wrap gap-2">
{appStoreApp.webLink && (
<ExternalLinkButton to={appStoreApp.webLink} variant="outline">
<GlobeIcon />
Website
</ExternalLinkButton>
)}
{appStoreApp.playLink && (
<ExternalLinkButton to={appStoreApp.playLink} variant="outline">
<PlayStoreIcon />
Play Store
</ExternalLinkButton>
)}
{appStoreApp.appleLink && (
<ExternalLinkButton to={appStoreApp.appleLink} variant="outline">
<AppleIcon />
App Store
</ExternalLinkButton>
)}
{appStoreApp.zapStoreLink && (
<ExternalLinkButton to={appStoreApp.zapStoreLink} variant="outline">
<ZapStoreIcon />
Zapstore
</ExternalLinkButton>
)}
{appStoreApp.chromeLink && (
<ExternalLinkButton to={appStoreApp.chromeLink} variant="outline">
<ChromeIcon />
Chrome Web Store
</ExternalLinkButton>
)}
{appStoreApp.firefoxLink && (
<ExternalLinkButton to={appStoreApp.firefoxLink} variant="outline">
<FirefoxIcon />
Firefox Add-Ons
</ExternalLinkButton>
)}
</div>
);
}
export function AppLinksCard({ appStoreApp }: { appStoreApp: AppStoreApp }) {
if (
!appStoreApp.appleLink &&
!appStoreApp.playLink &&
!appStoreApp.zapStoreLink &&
!appStoreApp.chromeLink &&
!appStoreApp.firefoxLink &&
!appStoreApp.webLink
) {
if (!hasAppLinks(appStoreApp)) {
return null;
}
@ -30,43 +81,8 @@ export function AppLinksCard({ appStoreApp }: { appStoreApp: AppStoreApp }) {
<CardHeader>
<CardTitle className="text-2xl">Links</CardTitle>
</CardHeader>
<CardFooter className="flex flex-row flex-wrap gap-2">
{appStoreApp.webLink && (
<ExternalLinkButton to={appStoreApp.webLink} variant="outline">
<GlobeIcon />
Website
</ExternalLinkButton>
)}
{appStoreApp.playLink && (
<ExternalLinkButton to={appStoreApp.playLink} variant="outline">
<PlayStoreIcon />
Play Store
</ExternalLinkButton>
)}
{appStoreApp.appleLink && (
<ExternalLinkButton to={appStoreApp.appleLink} variant="outline">
<AppleIcon />
App Store
</ExternalLinkButton>
)}
{appStoreApp.zapStoreLink && (
<ExternalLinkButton to={appStoreApp.zapStoreLink} variant="outline">
<ZapStoreIcon />
Zapstore
</ExternalLinkButton>
)}
{appStoreApp.chromeLink && (
<ExternalLinkButton to={appStoreApp.chromeLink} variant="outline">
<ChromeIcon />
Chrome Web Store
</ExternalLinkButton>
)}
{appStoreApp.firefoxLink && (
<ExternalLinkButton to={appStoreApp.firefoxLink} variant="outline">
<FirefoxIcon />
Firefox Add-Ons
</ExternalLinkButton>
)}
<CardFooter>
<AppLinks appStoreApp={appStoreApp} />
</CardFooter>
</Card>
);

View file

@ -72,31 +72,34 @@ export function AppUsage({ app }: { app: App }) {
deletingLightningAddress,
} = useDeleteLightningAddress(app.id);
const isSubwallet =
app.metadata?.app_store_app_id === SUBWALLET_APPSTORE_APP_ID;
return (
<>
{app.isolated && (
<div
className={cn(
"grid grid-cols-1 gap-3",
app.metadata?.app_store_app_id === SUBWALLET_APPSTORE_APP_ID &&
"lg:grid-cols-2"
)}
>
<Card className="justify-between">
<CardHeader>
<CardTitle>Isolated Balance</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between items-end">
<div>
<p className="font-medium text-2xl">
<FormattedBitcoinAmount amountMsat={app.balanceMsat} />
</p>
<FormattedFiatAmount
amountSat={Math.floor(app.balanceMsat / 1000)}
/>
</div>
<div className="flex gap-2 items-center">
<Card className="slashed-zero gap-4">
<CardHeader>
<CardTitle>Usage</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div
className={cn(
"grid grid-cols-1 gap-6 sm:gap-4",
app.isolated ? "sm:grid-cols-3" : "sm:grid-cols-2"
)}
>
{app.isolated && (
<div className="flex flex-col">
<p className="text-xs text-secondary-foreground font-medium">
Isolated Balance
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount amountMsat={app.balanceMsat} />
</p>
<FormattedFiatAmount
amountSat={Math.floor(app.balanceMsat / 1000)}
/>
<div className="flex gap-2 items-center mt-3">
{app.balanceMsat > 0 && (
<IsolatedAppDrawDownDialog appId={app.id}>
<Button size="sm" variant="outline">
@ -113,156 +116,138 @@ export function AppUsage({ app }: { app: App }) {
</IsolatedAppTopupDialog>
</div>
</div>
</CardContent>
</Card>
{app.metadata?.app_store_app_id === SUBWALLET_APPSTORE_APP_ID && (
<Card className="justify-between">
<CardHeader>
<CardTitle>Lightning Address</CardTitle>
{!app.metadata.lud16 && (
<CardDescription>
<p className="text-muted-foreground">
Create a lightning address for this particular app
connection
</p>
</CardDescription>
)}
</CardHeader>
<CardContent>
{!app.metadata.lud16 && (
<div className="flex items-center gap-2">
<InputWithAdornment
type="text"
value={intendedLightningAddress}
onChange={(e) =>
setIntendedLightningAddress(e.target.value)
}
required
autoComplete="off"
endAdornment={
<span className="mr-1 text-muted-foreground text-xs">
@getalby.com
</span>
}
className="flex-1"
/>
{!albyMe?.subscription.plan_code ? (
<UpgradeDialog>
<Button className="shrink-0" variant="secondary">
Create
</Button>
</UpgradeDialog>
) : (
<LoadingButton
className="shrink-0"
variant="secondary"
loading={creatingLightningAddress}
onClick={() =>
createLightningAddress(intendedLightningAddress)
}
>
Create
</LoadingButton>
)}
</div>
)}
{app.metadata.lud16 && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<p className="font-semibold break-all min-w-0 flex-1">
{app.metadata.lud16}
</p>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
onClick={() =>
copyToClipboard(app.metadata?.lud16 || "")
}
variant="outline"
>
<CopyIcon />
Copy
</Button>
<LoadingButton
size="sm"
variant="outline"
loading={deletingLightningAddress}
onClick={deleteSubwalletLightningAddress}
>
<Trash2Icon />
Remove
</LoadingButton>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card>
<CardHeader>
<CardTitle>Total Spent</CardTitle>
</CardHeader>
<CardContent>
<p className="font-medium text-2xl">
<FormattedBitcoinAmount amountMsat={totalSpentSat * 1000} />
</p>
<FormattedFiatAmount amountSat={totalSpentSat} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Total Received</CardTitle>
</CardHeader>
<CardContent>
<p className="font-medium text-2xl">
<FormattedBitcoinAmount amountMsat={totalReceivedSat * 1000} />
</p>
<FormattedFiatAmount amountSat={totalReceivedSat} />
</CardContent>
</Card>
</div>
{app.maxAmountSat > 0 && (
<Card>
<CardHeader>
<CardTitle>Budget</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-row justify-between mb-2">
<div>
<p className="text-xs text-secondary-foreground font-medium">
Left in budget
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount
amountMsat={app.maxAmountMsat - app.budgetUsageMsat}
/>
</p>
<FormattedFiatAmount
amountSat={app.maxAmountSat - app.budgetUsageSat}
/>
</div>
<div>
<p className="text-xs text-secondary-foreground font-medium">
Budget renewal
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount amountMsat={app.maxAmountMsat} />
{app.budgetRenewal !== "never" && (
<> / {getBudgetRenewalLabel(app.budgetRenewal)}</>
)}
</p>
<FormattedFiatAmount amountSat={app.maxAmountSat} />
</div>
)}
<div className="flex flex-col">
<p className="text-xs text-secondary-foreground font-medium">
Total Spent
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount amountMsat={totalSpentSat * 1000} />
</p>
<FormattedFiatAmount amountSat={totalSpentSat} />
</div>
<Progress
className="h-4"
value={100 - (app.budgetUsageSat * 100) / app.maxAmountSat}
/>
<div className="flex flex-col">
<p className="text-xs text-secondary-foreground font-medium">
Total Received
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount amountMsat={totalReceivedSat * 1000} />
</p>
<FormattedFiatAmount amountSat={totalReceivedSat} />
</div>
</div>
{app.maxAmountSat > 0 && (
<div className="flex flex-col gap-2 border-t pt-5">
<div className="flex flex-row justify-between">
<div>
<p className="text-xs text-secondary-foreground font-medium">
Left in budget
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount
amountMsat={app.maxAmountMsat - app.budgetUsageMsat}
/>
</p>
<FormattedFiatAmount
amountSat={app.maxAmountSat - app.budgetUsageSat}
/>
</div>
<div className="text-right">
<p className="text-xs text-secondary-foreground font-medium">
Budget renewal
</p>
<p className="text-xl font-medium">
<FormattedBitcoinAmount amountMsat={app.maxAmountMsat} />
{app.budgetRenewal !== "never" && (
<> / {getBudgetRenewalLabel(app.budgetRenewal)}</>
)}
</p>
<FormattedFiatAmount amountSat={app.maxAmountSat} />
</div>
</div>
<Progress
className="h-2 mt-1"
value={100 - (app.budgetUsageSat * 100) / app.maxAmountSat}
/>
</div>
)}
</CardContent>
</Card>
{app.isolated && isSubwallet && (
<Card>
<CardHeader>
<CardTitle>Lightning Address</CardTitle>
{!app.metadata?.lud16 && (
<CardDescription>
Create a lightning address for this particular app connection
</CardDescription>
)}
</CardHeader>
<CardContent>
{!app.metadata?.lud16 && (
<div className="flex items-center gap-2">
<InputWithAdornment
type="text"
value={intendedLightningAddress}
onChange={(e) => setIntendedLightningAddress(e.target.value)}
required
autoComplete="off"
endAdornment={
<span className="mr-1 text-muted-foreground text-xs">
@getalby.com
</span>
}
className="flex-1"
/>
{!albyMe?.subscription.plan_code ? (
<UpgradeDialog>
<Button className="shrink-0" variant="secondary">
Create
</Button>
</UpgradeDialog>
) : (
<LoadingButton
className="shrink-0"
variant="secondary"
loading={creatingLightningAddress}
onClick={() =>
createLightningAddress(intendedLightningAddress)
}
>
Create
</LoadingButton>
)}
</div>
)}
{app.metadata?.lud16 && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<p className="font-semibold break-all min-w-0 flex-1">
{app.metadata.lud16}
</p>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
onClick={() => copyToClipboard(app.metadata?.lud16 || "")}
variant="outline"
>
<CopyIcon />
Copy
</Button>
<LoadingButton
size="sm"
variant="outline"
loading={deletingLightningAddress}
onClick={deleteSubwalletLightningAddress}
>
<Trash2Icon />
Remove
</LoadingButton>
</div>
</div>
)}
</CardContent>
</Card>
)}

View file

@ -4,6 +4,8 @@ import { Link, useLocation, useParams } from "react-router";
import {
App,
AppPermissions,
BudgetRenewalType,
Scope,
UpdateAppRequest,
WalletCapabilities,
} from "src/types";
@ -24,8 +26,8 @@ import {
import { toast } from "sonner";
import AppAvatar from "src/components/AppAvatar";
import AppHeader from "src/components/AppHeader";
import { AboutAppCard } from "src/components/connections/AboutAppCard";
import { AppLinksCard } from "src/components/connections/AppLinksCard";
import { AppLinks } from "src/components/connections/AppLinksCard";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import { AppTransactionList } from "src/components/connections/AppTransactionList";
import { AppUsage } from "src/components/connections/AppUsage";
import { ConnectionDetailsModal } from "src/components/connections/ConnectionDetailsModal";
@ -69,6 +71,95 @@ import { useAppsForAppStoreApp } from "src/hooks/useApps";
import { useCapabilities } from "src/hooks/useCapabilities";
import { cn, getAppDisplayName } from "src/lib/utils";
function formatList(items: string[]): string {
if (items.length === 1) {
return items[0];
}
if (items.length === 2) {
return `${items[0]} and ${items[1]}`;
}
return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
}
const budgetPeriodLabels: Record<BudgetRenewalType, string> = {
daily: "day",
weekly: "week",
monthly: "month",
yearly: "year",
never: "",
"": "",
};
function getSecondaryAbilities(scopes: Scope[]): string[] {
const abilities: string[] = [];
if (scopes.includes("make_invoice")) {
abilities.push("receive payments");
}
if (
scopes.includes("get_balance") ||
scopes.includes("get_info") ||
scopes.includes("list_transactions") ||
scopes.includes("lookup_invoice")
) {
abilities.push("read your balance and activity");
}
if (scopes.includes("sign_message")) {
abilities.push("sign messages");
}
return abilities;
}
function PermissionsSummary({
scopes,
maxAmountSat,
budgetRenewal,
}: {
scopes: Scope[];
maxAmountSat: number;
budgetRenewal: BudgetRenewalType;
}) {
if (scopes.includes("superuser")) {
return (
<p>
This app has full access and can create other connections to your
wallet.
</p>
);
}
const canSpend = scopes.includes("pay_invoice");
const secondary = getSecondaryAbilities(scopes);
if (!canSpend && !secondary.length) {
return <p>This app has no access to your wallet.</p>;
}
if (!canSpend) {
return <p>This app can {formatList(secondary)}.</p>;
}
return (
<p>
This app can send payments
{maxAmountSat > 0 ? (
<>
{" "}
up to{" "}
<span className="font-medium text-foreground">
<FormattedBitcoinAmount amountMsat={maxAmountSat * 1000} />
</span>
{budgetRenewal !== "never" && (
<> per {budgetPeriodLabels[budgetRenewal]}</>
)}
</>
) : (
<> with no spending limit set</>
)}
.{!!secondary.length && <> It can also {formatList(secondary)}.</>}
</p>
);
}
function AppDetails() {
const { id } = useParams() as { id: string };
const { data: app, mutate: refetchApp, error } = useApp(parseInt(id));
@ -105,6 +196,8 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
React.useState(false);
const [showDisconnectAppDialog, setShowDisconnectAppDialog] =
React.useState(false);
const [showPermissionDetails, setShowPermissionDetails] =
React.useState(false);
const { data: albyMe } = useAlbyMe();
@ -190,6 +283,46 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
(!app.scopes.includes("pay_invoice") &&
permissions.scopes.includes("pay_invoice"));
const permissionsSection = (
<div>
<div className="flex flex-row justify-between items-center mb-2">
<p className="font-semibold">Permissions</p>
<button
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={() => setShowPermissionDetails((current) => !current)}
>
{showPermissionDetails ? "Hide details" : "Show details"}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
showPermissionDetails && "rotate-180"
)}
/>
</button>
</div>
{showPermissionDetails ? (
<Permissions
capabilities={capabilities}
permissions={savedPermissions}
setPermissions={setPermissions}
readOnly
/>
) : (
<div className="text-sm text-muted-foreground space-y-1">
<PermissionsSummary
scopes={app.scopes}
maxAmountSat={app.maxAmountSat}
budgetRenewal={app.budgetRenewal}
/>
{app.expiresAt && (
<p>Expires {new Date(app.expiresAt).toLocaleDateString()}</p>
)}
</div>
)}
</div>
);
return (
<>
<div className="w-full">
@ -390,17 +523,6 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
}
description={""}
/>
{!isEditingPermissions && (
<>
{appStoreApp && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<AboutAppCard appStoreApp={appStoreApp} />
<AppLinksCard appStoreApp={appStoreApp} />
</div>
)}
<AppUsage app={app} />
</>
)}
{isEditingPermissions ? (
<form
id="app-details"
@ -453,23 +575,43 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
</Card>
</form>
) : (
<Card>
<CardHeader>
<CardTitle>
<div className="flex flex-row justify-between items-center">
Permissions
</div>
</CardTitle>
</CardHeader>
<CardContent>
<Permissions
capabilities={capabilities}
permissions={savedPermissions}
setPermissions={setPermissions}
readOnly
/>
</CardContent>
</Card>
<div
className={cn(
"grid grid-cols-1 gap-3 items-start",
appStoreApp && "lg:grid-cols-3"
)}
>
<div
className={cn(
"flex flex-col gap-3 min-w-0",
appStoreApp && "lg:col-span-2"
)}
>
<AppUsage app={app} />
{!appStoreApp && (
<Card>
<CardContent className="py-6">
{permissionsSection}
</CardContent>
</Card>
)}
<AppTransactionList appId={app.id} />
</div>
{appStoreApp && (
<Card className="gap-4">
<CardHeader>
<CardTitle>About the App</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-muted-foreground">
{appStoreApp.extendedDescription}
</p>
<AppLinks appStoreApp={appStoreApp} />
<div className="border-t pt-4">{permissionsSection}</div>
</CardContent>
</Card>
)}
</div>
)}
{!isEditingPermissions && (
<>
@ -485,7 +627,6 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
onClose={() => setShowDisconnectAppDialog(false)}
/>
)}
<AppTransactionList appId={app.id} />
</>
)}
</div>