refactor: externalize LoginCard

This commit is contained in:
theborakompanioni 2026-02-13 17:59:17 +01:00
parent 3c3e83448c
commit 7bab28c1be
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
4 changed files with 211 additions and 91 deletions

View file

@ -0,0 +1,127 @@
import type { ComponentProps } from 'react'
import type { ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { AlertCircleIcon, RefreshCwIcon, WalletIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { routes } from '@/constants/routes'
import { cn } from '@/lib/utils'
import { LoginForm } from './LoginForm'
type LoginFormProps = ComponentProps<typeof LoginForm>
type LoginCardProps = Omit<LoginFormProps, 'loading' | 'onSubmit'> &
Required<Pick<LoginFormProps, 'onSubmit'>> & {
isSubmitting: boolean
listWalletsFetching: boolean
listWalletsLoading: boolean
listWalletsError?: ErrorMessage
onReloadClick: () => Promise<void>
}
export const LoginCard = ({
wallets,
activeWallet,
makerRunning,
coinjoinInProgress,
onSubmit,
isSubmitting,
listWalletsLoading,
listWalletsFetching,
listWalletsError,
onReloadClick,
}: LoginCardProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
return (
<Card className="w-full max-w-md">
<CardHeader className="flex flex-col items-center space-y-2">
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<WalletIcon className="text-primary" onClick={() => void onReloadClick()} />
)}
</div>
<CardTitle className="text-2xl font-bold">{/*TODO: i18n */}Welcome to Jam</CardTitle>
{listWalletsLoading ? (
<>
<Skeleton className="h-4 w-full" />
</>
) : wallets && wallets.length > 0 ? (
<CardDescription>{/*TODO: i18n */}Select a wallet and enter your password to continue.</CardDescription>
) : undefined}
</CardHeader>
<CardContent className="space-y-6">
{listWalletsError ? (
<>
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{t('wallets.error_loading_failed')}</AlertTitle>
<AlertDescription>{listWalletsError.message || t('global.errors.reason_unknown')}</AlertDescription>
</Alert>
<Button variant="ghost" size="sm" onClick={() => void onReloadClick()} disabled={listWalletsFetching}>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': listWalletsFetching })} />
{t('global.retry')}
</Button>
</>
) : (
<>
{wallets === undefined || listWalletsLoading ? (
<>
<LoginForm loading />
<div className="flex flex-col gap-2">
<div>&nbsp;</div>
<div>&nbsp;</div>
</div>
</>
) : (
<>
{wallets && wallets.length === 0 ? (
<div className="text-center">
<p className="text-muted-foreground text-sm">{t('wallets.subtitle_no_wallets')}</p>
</div>
) : (
<LoginForm
wallets={wallets}
activeWallet={activeWallet}
makerRunning={makerRunning ?? false}
coinjoinInProgress={coinjoinInProgress ?? false}
disabled={isSubmitting || listWalletsFetching}
onSubmit={onSubmit}
/>
)}
<div className="flex flex-col gap-2">
<Button
variant={wallets.length === 0 ? 'default' : 'link'}
size={wallets.length === 0 ? 'xxl' : 'default'}
onClick={() => void navigate(routes.createWallet)}
>
{t('wallets.button_new_wallet')}
</Button>
<Button
variant={wallets.length === 0 ? 'secondary' : 'link'}
size={wallets.length === 0 ? 'xxl' : 'default'}
onClick={() => void navigate('/import-wallet')}
disabled
>
{/* TODO: implement "import wallet" */}
{t('wallets.button_import_wallet')}
<Badge variant="destructive">Not yet implemented.</Badge>
</Button>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
)
}

View file

@ -184,8 +184,6 @@ type LoginFormLoadingProps = {
}
type LoginFormProps =
| LoginFormLoadingProps
| LoginFormComponentProps
| (LoginFormLoadingProps & Partial<LoginFormComponentProps>)
| ({ loading?: false } & LoginFormComponentProps)

View file

@ -1,25 +1,18 @@
import { listwalletsOptions, unlockwalletMutation } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { AlertCircleIcon, RefreshCwIcon, WalletIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useStore } from 'zustand'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { routes } from '@/constants/routes'
import { useApiClient } from '@/hooks/useApiClient'
import { hashPassword } from '@/lib/hash'
import { withQueryDelay } from '@/lib/queryClient'
import { cn, sortWallets } from '@/lib/utils'
import { sortWallets } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { authStore, type AuthState } from '@/store/authStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { LoginForm } from './LoginForm'
import { LoginCard } from './LoginCard'
interface LoginFormData {
walletFileName: WalletFileName
@ -107,86 +100,19 @@ const LoginPage = () => {
return (
<div className="from-background to-muted flex min-h-screen items-center justify-center bg-gradient-to-br p-4">
<Card className="w-full max-w-md">
<CardHeader className="flex flex-col items-center space-y-2">
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<WalletIcon className="text-primary" onClick={() => void listWalletsRefetch()} />
)}
</div>
<CardTitle className="text-2xl font-bold">{/*TODO: i18n */}Welcome to Jam</CardTitle>
{listWalletsLoading ? (
<>
<Skeleton className="h-4 w-full" />
</>
) : wallets.length > 0 ? (
<CardDescription>{/*TODO: i18n */}Select a wallet and enter your password to continue.</CardDescription>
) : undefined}
</CardHeader>
<CardContent className="space-y-6">
{listWalletsError ? (
<>
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>{t('wallets.error_loading_failed')}</AlertTitle>
<AlertDescription>{listWalletsError.message || t('global.errors.reason_unknown')}</AlertDescription>
</Alert>
<Button
variant="ghost"
size="sm"
onClick={() => void listWalletsRefetch()}
disabled={listWalletsFetching}
>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': listWalletsFetching })} />
{t('global.retry')}
</Button>
</>
) : (
<>
{listWalletsLoading ? (
<>
<LoginForm loading />
<div className="flex flex-col gap-2">
<div>&nbsp;</div>
<div>&nbsp;</div>
</div>
</>
) : (
<>
{wallets.length === 0 ? (
<div className="text-center">
<p className="text-muted-foreground text-sm">{t('wallets.subtitle_no_wallets')}</p>
</div>
) : (
<LoginForm
wallets={wallets}
activeWallet={activeWalletOrNull ?? undefined}
makerRunning={makerRunning}
coinjoinInProgress={coinjoinInProgress}
disabled={login.isPending || listWalletsFetching}
onSubmit={handleSubmit}
/>
)}
<div className="flex flex-col gap-2">
<Button variant="link" size="sm" onClick={() => void navigate(routes.createWallet)}>
{t('wallets.button_new_wallet')}
</Button>
<Button variant="link" size="sm" onClick={() => void navigate('/import-wallet')} disabled>
{/* TODO: implement "import wallet" */}
{t('wallets.button_import_wallet')}
<Badge variant="destructive">Not yet implemented.</Badge>
</Button>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
<LoginCard
wallets={wallets}
activeWallet={activeWalletOrNull ?? undefined}
makerRunning={makerRunning}
coinjoinInProgress={coinjoinInProgress}
disabled={login.isPending || listWalletsFetching}
onSubmit={handleSubmit}
isSubmitting={login.isPending}
listWalletsFetching={listWalletsFetching}
listWalletsLoading={listWalletsLoading}
listWalletsError={listWalletsError ?? undefined}
onReloadClick={async () => void (await listWalletsRefetch())}
/>
</div>
)
}

View file

@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { LoginCard } from '@/components/login/LoginCard'
import type { WalletFileName } from '@/lib/utils'
const meta: Meta<typeof LoginCard> = {
title: 'Page/Login/LoginCard',
component: LoginCard,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof LoginCard>
const WALLETS = [
'Satoshi.jmdat',
'Wallet With Whitespaces.jmdat',
'a.jmdat',
'A very long wallet name that should be shortened but show start and end of name.jmdat',
] as WalletFileName[]
export const Default: Story = {
args: {
wallets: WALLETS,
activeWallet: undefined,
makerRunning: false,
coinjoinInProgress: false,
disabled: false,
onSubmit: async () => alert('Submit clicked!'),
},
argTypes: {
activeWallet: {
control: 'select',
options: [...WALLETS, 'None'],
},
},
}
export const Empty: Story = {
args: {
wallets: [],
activeWallet: undefined,
makerRunning: false,
coinjoinInProgress: false,
disabled: false,
onSubmit: async () => alert('Submit clicked!'),
},
}
export const Loading: Story = {
args: {
listWalletsLoading: true,
},
}
export const Fetching: Story = {
args: {
listWalletsFetching: true,
wallets: WALLETS,
},
}
export const Error: Story = {
args: {
listWalletsError: {
message: 'Wallet loading failed.',
error_description: '500 Server Error',
},
onReloadClick: async () => alert('Reload clicked!'),
},
}