refactor: clean up first channel screen

This commit is contained in:
saunter 2026-05-19 18:26:54 +02:00
parent b0d0c4ed5b
commit ff27c7019e
3 changed files with 444 additions and 320 deletions

View file

@ -7,9 +7,9 @@ const pollConfiguration: SWRConfiguration = {
refreshInterval: 3000,
};
export function useChannels(poll = false) {
export function useChannels(poll = false, enabled = true) {
return useSWR<Channel[]>(
"/api/channels",
enabled ? "/api/channels" : undefined,
swrFetcher,
poll ? pollConfiguration : undefined
);

View file

@ -3,6 +3,9 @@ import useSWR from "swr";
import { LSPChannelOffer } from "src/types";
import { swrFetcher } from "src/utils/swr";
export function useLSPChannelOffer() {
return useSWR<LSPChannelOffer>("/api/channel-offer", swrFetcher);
export function useLSPChannelOffer(enabled = true) {
return useSWR<LSPChannelOffer>(
enabled ? "/api/channel-offer" : undefined,
swrFetcher
);
}

View file

@ -1,84 +1,150 @@
import { ChevronDownIcon, InfoIcon } from "lucide-react";
import { CreditCardIcon } from "lucide-react";
import React from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import Loading from "src/components/Loading";
import { MempoolAlert } from "src/components/MempoolAlert";
import { PayLightningInvoice } from "src/components/PayLightningInvoice";
import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
import { LSPTermsDialog } from "src/components/channels/LSPTermsDialog";
import { Button } from "src/components/ui/button";
import { Checkbox } from "src/components/ui/checkbox";
import { LoadingButton } from "src/components/ui/custom/loading-button";
import { Label } from "src/components/ui/label";
import { Separator } from "src/components/ui/separator";
import { useChannels } from "src/hooks/useChannels";
import { useInfo } from "src/hooks/useInfo";
import {
AutoChannelRequest,
AutoChannelResponse,
LSPChannelOfferPaymentMethod,
} from "src/types";
import { request } from "src/utils/request";
import { Invoice } from "@getalby/lightning-tools";
import { MempoolAlert } from "src/components/MempoolAlert";
import { PayLightningInvoice } from "src/components/PayLightningInvoice";
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
import LightningNetworkDarkSVG from "public/images/illustrations/lightning-network-dark.svg";
import LightningNetworkLightSVG from "public/images/illustrations/lightning-network-light.svg";
import { LSPTermsDialog } from "src/components/channels/LSPTermsDialog";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
import { LinkButton } from "src/components/ui/custom/link-button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "src/components/ui/dialog";
import {
Field,
FieldContent,
FieldLabel,
FieldTitle,
} from "src/components/ui/field";
import { Label } from "src/components/ui/label";
import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "src/components/ui/tooltip";
import { Separator } from "src/components/ui/separator";
import { useChannels } from "src/hooks/useChannels";
import { useInfo } from "src/hooks/useInfo";
import { useLSPChannelOffer } from "src/hooks/useLSPChannelOffer";
import { cn } from "src/lib/utils";
import {
AutoChannelRequest,
AutoChannelResponse,
LSPChannelOffer,
LSPChannelOfferPaymentMethod,
} from "src/types";
import { openLink } from "src/utils/openLink";
import { request } from "src/utils/request";
export function FirstChannel() {
const { data: info } = useInfo();
const { data: channels } = useChannels(true);
import LightningNetworkDarkSVG from "public/images/illustrations/lightning-network-dark.svg";
import LightningNetworkLightSVG from "public/images/illustrations/lightning-network-light.svg";
type FirstChannelProps = {
variant?: "app" | "setup";
};
const DEFAULT_PAYMENT_METHOD = "wallet" satisfies LSPChannelOfferPaymentMethod;
const PAYMENT_DETAILS_URL = "https://getalby.com/payment_details";
export function FirstChannel({ variant = "app" }: FirstChannelProps) {
const isSetup = variant === "setup";
const { data: info, hasChannelManagement } = useInfo();
const shouldLoadChannelData =
!info || (!isSetup && !info.albyAccountConnected)
? true
: Boolean(hasChannelManagement && info.albyAccountConnected);
const { data: channels } = useChannels(true, shouldLoadChannelData);
const { data: lspChannelOffer } = useLSPChannelOffer(
shouldLoadChannelData && Boolean(info?.albyAccountConnected)
);
const [isLoading, setLoading] = React.useState(false);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [isPublic, setPublic] = React.useState(false);
const { data: lspChannelOffer } = useLSPChannelOffer();
const navigate = useNavigate();
const [invoice, setInvoice] = React.useState<string>();
const [channelSizeSat, setChannelSizeSat] = React.useState<number>();
const [currentPaymentMethod, setCurrentPaymentMethod] =
React.useState<LSPChannelOfferPaymentMethod>();
React.useState<LSPChannelOfferPaymentMethod>(DEFAULT_PAYMENT_METHOD);
const openingPath = isSetup
? "/setup/first-channel/opening"
: "/channels/first/opening";
React.useEffect(() => {
if (channels?.length) {
navigate("/channels/first/opening");
navigate(openingPath);
}
}, [channels, navigate]);
}, [channels, navigate, openingPath]);
React.useEffect(() => {
if (info && !info.albyAccountConnected) {
if (!isSetup && info && !info.albyAccountConnected) {
navigate("/channels/incoming");
}
}, [info, navigate]);
}, [info, isSetup, navigate]);
if (!info?.albyAccountConnected || !channels || !lspChannelOffer) {
React.useEffect(() => {
if (isSetup && info && !info.albyAccountConnected) {
navigate("/home", { replace: true });
}
}, [info, isSetup, navigate]);
React.useEffect(() => {
if (!lspChannelOffer) {
return;
}
if (
lspChannelOffer.currentPaymentMethod === "card" ||
lspChannelOffer.currentPaymentMethod === "wallet"
) {
setCurrentPaymentMethod(lspChannelOffer.currentPaymentMethod);
}
}, [lspChannelOffer]);
if (!info || (shouldLoadChannelData && !channels)) {
return <Loading />;
}
if (isSetup && !hasChannelManagement) {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<TwoColumnLayoutHeader
title="Your Hub is ready"
pageTitle="Your Hub is ready"
description="This wallet setup does not need Lightning channels."
/>
<LinkButton to="/home" className="w-full justify-center">
Go to dashboard
</LinkButton>
</div>
);
}
if (!info.albyAccountConnected) {
if (isSetup) {
return null;
}
return <Loading />;
}
if (!lspChannelOffer) {
return <Loading />;
}
const showPaymentMethod =
lspChannelOffer.currentPaymentMethod !== "prepaid" &&
lspChannelOffer.currentPaymentMethod !== "included";
const needsPaymentCard =
currentPaymentMethod === "card" &&
lspChannelOffer.currentPaymentMethod !== "card";
async function openChannel() {
if (!info || !channels || !lspChannelOffer) {
return;
@ -88,6 +154,9 @@ export function FirstChannel() {
lspChannelOffer.currentPaymentMethod === "wallet") &&
currentPaymentMethod !== lspChannelOffer.currentPaymentMethod
) {
if (currentPaymentMethod === "card") {
openLink(PAYMENT_DETAILS_URL);
}
toast.error("Payment method incorrectly configured", {
description: currentPaymentMethod
? "Please switch the payment method and confirm the change in your Alby Account settings"
@ -125,293 +194,345 @@ export function FirstChannel() {
return (
<>
<AppHeader
pageTitle="Open Your First Channel"
title="Open Your First Channel"
description="Open a channel to another lightning network node to join the lightning network"
/>
<MempoolAlert />
{invoice && channelSizeSat && (
<div className="flex flex-col gap-4 items-center justify-center max-w-md">
<p className="text-muted-foreground slashed-zero">
Alby Hub works with selected service providers (LSPs) which provide
the best network connectivity and liquidity to receive payments. To
quickly get started you can buy a channel from an LSP by paying the
lightning invoice below.
</p>
<div className="border rounded-lg slashed-zero w-full">
<Table>
<TableBody>
<TableRow>
<TableCell className="font-medium p-3">
Incoming Liquidity
</TableCell>
<TableCell className="text-right p-3">
<FormattedBitcoinAmount
amountMsat={channelSizeSat * 1000}
/>
</TableCell>
</TableRow>
{invoice && (
<TableRow>
<TableCell className="font-medium p-3">
Amount to pay
</TableCell>
<TableCell className="font-semibold text-right p-3">
<FormattedBitcoinAmount
amountMsat={new Invoice({ pr: invoice }).satoshi * 1000}
/>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<PayLightningInvoice invoice={invoice} />
<Separator className="mt-2" />
<p className="mt-8 text-sm mb-2 text-muted-foreground">
Other options
</p>
<LinkButton
to="/channels/outgoing"
variant="secondary"
className="w-full"
>
Open Channel with On-Chain Bitcoin
</LinkButton>
<ExternalLinkButton
to="https://www.getalby.com/topup"
variant="secondary"
className="w-full"
>
Buy Bitcoin
</ExternalLinkButton>
</div>
{isSetup ? (
<title>Open Lightning Channel · Alby Hub</title>
) : (
<AppHeader
pageTitle="Open Lightning Channel"
title="Open Lightning Channel"
description="Instantly open a Lightning channel before you start using your Hub."
/>
)}
{!invoice && (
<>
<div className="flex flex-col gap-6 max-w-md text-muted-foreground">
<img
src={LightningNetworkDarkSVG}
className="w-full hidden dark:block"
{invoice && channelSizeSat ? (
<FirstChannelPayment
channelSizeSat={channelSizeSat}
invoice={invoice}
lspChannelOffer={lspChannelOffer}
/>
) : (
<div className="flex flex-col gap-6 max-w-md text-muted-foreground">
<FirstChannelIntro lspChannelOffer={lspChannelOffer} />
{showPaymentMethod && (
<PaymentMethodSelector
currentPaymentMethod={currentPaymentMethod}
onPaymentMethodChange={setCurrentPaymentMethod}
/>
<img
src={LightningNetworkLightSVG}
className="w-full dark:hidden"
/>
<p>
You're now going to open your first lightning channel and can
begin using your Hub in the booming bitcoin economy!
</p>
<p className="text-muted-foreground">
Alby Hub works with selected service providers (LSPs) which
provide the best network connectivity and liquidity to receive
payments.
</p>
<div>
<h2 className="font-medium text-foreground mb-3">
Purchase Your First Channel
</h2>
<p>
A payment is required to purchase a channel from{" "}
<ExternalLink
to={lspChannelOffer.lspContactUrl}
className="underline"
>
{lspChannelOffer.lspName}
</ExternalLink>
. Once your channel is opened, you'll immediately be able to
receive and send bitcoin with your Hub.
</p>
)}
{needsPaymentCard && <AddPaymentCard />}
{!showAdvanced && (
<div className="flex justify-center">
<Button
type="button"
variant="ghost"
onClick={() => setShowAdvanced(true)}
>
Advanced Options
</Button>
</div>
)}
{lspChannelOffer.currentPaymentMethod !== "prepaid" &&
lspChannelOffer.currentPaymentMethod !== "included" && (
<div>
<h3 className="font-medium text-sm text-foreground mb-3">
Payment method
</h3>
<RadioGroup
value={currentPaymentMethod}
onValueChange={(
newPaymentMethod: LSPChannelOfferPaymentMethod
) => {
if (
newPaymentMethod !==
lspChannelOffer.currentPaymentMethod
) {
openLink("https://getalby.com/payment_details");
}
setCurrentPaymentMethod(newPaymentMethod);
}}
>
<FieldLabel htmlFor="wallet">
<Field orientation="horizontal">
<RadioGroupItem
value={
"wallet" satisfies LSPChannelOfferPaymentMethod
}
id="wallet"
/>
<FieldContent>
<FieldTitle>Bitcoin</FieldTitle>
</FieldContent>
</Field>
</FieldLabel>
<FieldLabel htmlFor="card">
<Field orientation="horizontal">
<RadioGroupItem
value={"card" satisfies LSPChannelOfferPaymentMethod}
id="card"
/>
<FieldContent>
<FieldTitle>Credit / Debit Card</FieldTitle>
</FieldContent>
</Field>
</FieldLabel>
</RadioGroup>
</div>
)}
{showAdvanced && (
<>
<div className="mt-2 flex items-top space-x-2">
<Checkbox
id="public-channel"
onCheckedChange={() => setPublic(!isPublic)}
className="mr-2"
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="public-channel"
className="cursor-pointer text-foreground"
>
Public Channel
</Label>
<p className="text-xs text-muted-foreground">
Not recommended for most users.{" "}
<ExternalLink
className="underline"
to="https://guides.getalby.com/user-guide/alby-hub/faq/should-i-open-a-private-or-public-channel"
>
Learn more
</ExternalLink>
</p>
</div>
</div>
</>
)}
{!showAdvanced && (
<div>
<Button
type="button"
variant="link"
className="text-muted-foreground text-xs px-0"
onClick={() => setShowAdvanced((current) => !current)}
>
Advanced Options
<ChevronDownIcon className="size-4" />
</Button>
</div>
)}
{showAdvanced && (
<PublicChannelOption isPublic={isPublic} setPublic={setPublic} />
)}
<Separator />
<Separator />
<Table>
<TableBody>
<TableRow className="border-0">
<TableCell className="px-3 align-top">
<div className="flex flex-1 items-center gap-1 text-sm">
You'll be able to receive up to{" "}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row items-center">
<InfoIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-sm">
The amount you will be able to receive when funds
are on your counterparty's side of the channel.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
<TableCell className="px-3 flex flex-col gap-2 items-end justify-center align-top">
{/* <span>
<FormattedBitcoinAmount
amountMsat={lspChannelOffer.lspBalanceSat * 1000}
/>
</span> */}
<FormattedFiatAmount
amountSat={lspChannelOffer.lspBalanceSat}
showApprox
/>
</TableCell>
</TableRow>
<TableRow className="border-0">
<TableCell className="p-3 text-sm">
Channel Cost
{lspChannelOffer.currentPaymentMethod === "included" &&
" (Included in your plan)"}
</TableCell>
<TableCell className="p-3 flex flex-col gap-2 items-end justify-center">
<p>
<span
className={cn(
lspChannelOffer.currentPaymentMethod === "included" &&
"line-through"
)}
>
{new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
}).format(lspChannelOffer.feeTotalUsd / 100)}
</span>
{lspChannelOffer.currentPaymentMethod === "included" && (
<span> $0.00</span>
)}
</p>
</TableCell>
</TableRow>
</TableBody>
</Table>
<div className="w-full flex items-center justify-center -mb-2">
<p className="text-center text-xs max-w-md">
By continuing, you consent to the{" "}
<LSPTermsDialog
contactUrl={lspChannelOffer.lspContactUrl}
description={lspChannelOffer.lspDescription}
name={lspChannelOffer.lspName}
terms={lspChannelOffer.terms}
trigger={<span className="underline">LSP terms</span>}
/>{" "}
and that the channel opens immediately and that you lose the
right to revoke once it is open.
</p>
</div>
<ChannelSummary lspChannelOffer={lspChannelOffer} />
<div className="flex flex-col gap-3">
<LoadingButton loading={isLoading} onClick={openChannel}>
{lspChannelOffer.currentPaymentMethod === "prepaid" ? (
<>Review Order</>
) : lspChannelOffer.currentPaymentMethod === "included" ? (
<>Open Channel</>
) : (
<>
Pay{" "}
{new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
}).format(lspChannelOffer.feeTotalUsd / 100)}{" "}
and Open Channel
</>
)}
{getOpenChannelCta(lspChannelOffer, currentPaymentMethod)}
</LoadingButton>
{isSetup && (
<LinkButton
to="/home"
variant="outline"
className="w-full justify-center"
>
Skip for Now
</LinkButton>
)}
</div>
</>
<p className="text-center text-xs text-muted-foreground">
By committing, you agree to the{" "}
<LSPTermsDialog
contactUrl={lspChannelOffer.lspContactUrl}
description={lspChannelOffer.lspDescription}
name={lspChannelOffer.lspName}
terms={lspChannelOffer.terms}
trigger={<span className="underline">LSP terms</span>}
/>
.
</p>
</div>
)}
</>
);
}
function FirstChannelIntro({
lspChannelOffer,
}: {
lspChannelOffer: LSPChannelOffer;
}) {
return (
<>
<MempoolAlert />
<div className="flex flex-col gap-2 items-center justify-center py-2 text-center">
<h1 className="font-semibold text-2xl text-foreground">
Open Lightning Channel
</h1>
<p className="text-sm text-muted-foreground">
Instantly open a lightning channel to another network node with our
partner{" "}
<ExternalLink
to={lspChannelOffer.lspContactUrl}
className="text-foreground underline"
>
{lspChannelOffer.lspName}
</ExternalLink>
. Once opened, you'll be able to send and receive bitcoin instantly.{" "}
<LearnMoreDialog />
</p>
</div>
</>
);
}
function PaymentMethodSelector({
currentPaymentMethod,
onPaymentMethodChange,
}: {
currentPaymentMethod: LSPChannelOfferPaymentMethod;
onPaymentMethodChange(paymentMethod: LSPChannelOfferPaymentMethod): void;
}) {
return (
<div className="flex flex-col gap-3">
<h2 className="font-medium text-sm text-foreground">Payment method</h2>
<RadioGroup
value={currentPaymentMethod}
onValueChange={(newPaymentMethod: LSPChannelOfferPaymentMethod) =>
onPaymentMethodChange(newPaymentMethod)
}
>
<FieldLabel htmlFor="wallet" className="text-foreground">
<Field orientation="horizontal">
<RadioGroupItem
value={"wallet" satisfies LSPChannelOfferPaymentMethod}
id="wallet"
/>
<FieldContent>
<FieldTitle className="text-foreground">Bitcoin</FieldTitle>
</FieldContent>
</Field>
</FieldLabel>
<FieldLabel htmlFor="card" className="text-foreground">
<Field orientation="horizontal">
<RadioGroupItem
value={"card" satisfies LSPChannelOfferPaymentMethod}
id="card"
/>
<FieldContent>
<FieldTitle className="text-foreground">
Credit / Debit Card
</FieldTitle>
</FieldContent>
</Field>
</FieldLabel>
</RadioGroup>
</div>
);
}
function AddPaymentCard() {
return (
<div className="border rounded-md p-4 flex items-start gap-4">
<div className="size-8 rounded-sm border bg-muted flex items-center justify-center shrink-0">
<CreditCardIcon className="size-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="text-sm font-medium text-foreground">
Add a payment card
</p>
<p className="text-sm text-muted-foreground">
Save your card in Alby Account settings to pay for the channel.
</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() => openLink(PAYMENT_DETAILS_URL)}
>
Add Card
</Button>
</div>
);
}
function PublicChannelOption({
isPublic,
setPublic,
}: {
isPublic: boolean;
setPublic(isPublic: boolean): void;
}) {
return (
<div className="flex items-start gap-2">
<Checkbox
id="public-channel"
checked={isPublic}
onCheckedChange={(checked) => setPublic(checked === true)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="public-channel"
className="cursor-pointer text-foreground"
>
Public channel
</Label>
<p className="text-sm text-muted-foreground">
Not recommended for most users.{" "}
<ExternalLink
className="underline"
to="https://guides.getalby.com/user-guide/alby-hub/faq/should-i-open-a-private-or-public-channel"
>
Learn more
</ExternalLink>
</p>
</div>
</div>
);
}
function ChannelSummary({
lspChannelOffer,
}: {
lspChannelOffer: LSPChannelOffer;
}) {
return (
<div className="grid gap-3">
<div className="flex items-start justify-between gap-4 text-sm">
<p className="text-muted-foreground">
You'll be able to receive up to:
</p>
<FormattedFiatAmount
amountSat={lspChannelOffer.lspBalanceSat}
className="font-medium text-foreground"
showApprox
/>
</div>
<div className="flex items-start justify-between gap-4 text-sm">
<p className="text-muted-foreground">Total channel cost:</p>
<p className="font-medium text-foreground">
<span
className={cn(
lspChannelOffer.currentPaymentMethod === "included" &&
"line-through"
)}
>
{new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
}).format(lspChannelOffer.feeTotalUsd / 100)}
</span>
{lspChannelOffer.currentPaymentMethod === "included" && (
<span> $0.00</span>
)}
</p>
</div>
</div>
);
}
function FirstChannelPayment({
channelSizeSat,
invoice,
lspChannelOffer,
}: {
channelSizeSat: number;
invoice: string;
lspChannelOffer: LSPChannelOffer;
}) {
return (
<div className="flex w-full max-w-md flex-col items-center justify-center gap-6">
<h1 className="text-center text-2xl font-semibold">Open First Channel</h1>
<PayLightningInvoice
invoice={invoice}
variant="first-channel"
incomingLiquiditySat={channelSizeSat}
lspTerms={{
name: lspChannelOffer.lspName,
description: lspChannelOffer.lspDescription,
contactUrl: lspChannelOffer.lspContactUrl,
terms: lspChannelOffer.terms,
}}
/>
</div>
);
}
function LearnMoreDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="text-foreground underline underline-offset-2"
>
Learn More
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Lightning Network Channels</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
Lightning channels allow you to send and receive bitcoin instantly
on the lightning network. Each channel has a certain capacity of how
much bitcoin it can receive.
</p>
<p className="text-sm text-muted-foreground">
Alby Hub opens lightning channels to selected liquidity providers
(LSPs) for optimal network connectivity and reliability.
</p>
<div className="overflow-hidden rounded-lg p-4">
<img
src={LightningNetworkDarkSVG}
className="hidden w-full dark:block"
alt=""
/>
<img
src={LightningNetworkLightSVG}
className="w-full dark:hidden"
alt=""
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function getOpenChannelCta(
lspChannelOffer: LSPChannelOffer,
currentPaymentMethod: LSPChannelOfferPaymentMethod
) {
if (lspChannelOffer.currentPaymentMethod === "prepaid") {
return "Review Order";
}
if (lspChannelOffer.currentPaymentMethod === "included") {
return "Open Channel";
}
if (currentPaymentMethod === "card") {
return `Pay ${new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
}).format(lspChannelOffer.feeTotalUsd / 100)} and Open Channel`;
}
return "Review Order";
}