diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index c1cbc8816..fe5417389 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,7 @@ import type { Preview } from '@storybook/react-vite' import { QueryClientProvider, QueryClient } from '@tanstack/react-query' import { ThemeProvider } from 'next-themes' import { I18nextProvider } from 'react-i18next' +import { MemoryRouter } from 'react-router-dom' import { CoreTypes, GlobalTypes } from 'storybook/internal/csf' import i18n from '../src/i18n/config' import '../src/index.css' @@ -63,6 +64,24 @@ const preview: Preview = { }, }, } + +const withTheme = (Story: React.ComponentType, context: GlobalContext) => { + const { backgrounds } = context.globals + return ( + + + + ) +} + +const withMemoryRouter = (Story: React.ComponentType, context: GlobalContext) => { + return ( + + + + ) +} + const withI18next = (Story: React.ComponentType, context: GlobalContext) => { const { locale } = context.globals @@ -79,15 +98,6 @@ const withI18next = (Story: React.ComponentType, context: GlobalContext) => { ) } -const withTheme = (Story: React.ComponentType, context: GlobalContext) => { - const { backgrounds } = context.globals - return ( - - - - ) -} - // Use only when necessary! Try to pass data from queries to // components so they can be tested independently from an API. export const withQueryClient = (Story: React.ComponentType) => { @@ -100,6 +110,6 @@ export const withQueryClient = (Story: React.ComponentType) => { } // export decorators for storybook to wrap your stories in -export const decorators = [withTheme, withI18next] +export const decorators = [withTheme, withMemoryRouter, withI18next] export default preview diff --git a/package-lock.json b/package-lock.json index 3d1c3b6f0..4ae20e9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ "zustand": "5.0.10" }, "devDependencies": { - "@chromatic-com/storybook": "4.1.3", + "@chromatic-com/storybook": "5.0.0", "@eslint/js": "9.39.2", "@playwright/test": "1.57.0", "@storybook/addon-a11y": "10.1.11", @@ -399,14 +399,14 @@ } }, "node_modules/@chromatic-com/storybook": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-4.1.3.tgz", - "integrity": "sha512-hc0HO9GAV9pxqDE6fTVOV5KeLpTiCfV8Jrpk5ogKLiIgeq2C+NPjpt74YnrZTjiK8E19fYcMP+2WY9ZtX7zHmw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz", + "integrity": "sha512-8wUsqL8kg6R5ue8XNE7Jv/iD1SuE4+6EXMIGIuE+T2loBITEACLfC3V8W44NJviCLusZRMWbzICddz0nU0bFaw==", "dev": true, "license": "MIT", "dependencies": { "@neoconfetti/react": "^1.0.0", - "chromatic": "^13.3.3", + "chromatic": "^13.3.4", "filesize": "^10.0.12", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" @@ -416,7 +416,7 @@ "yarn": ">=1.22.18" }, "peerDependencies": { - "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" } }, "node_modules/@conventional-changelog/git-client": { diff --git a/package.json b/package.json index 5a37d5500..85bbdacbf 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "zustand": "5.0.10" }, "devDependencies": { - "@chromatic-com/storybook": "4.1.3", + "@chromatic-com/storybook": "5.0.0", "@eslint/js": "9.39.2", "@playwright/test": "1.57.0", "@storybook/addon-a11y": "10.1.11", diff --git a/src/App.tsx b/src/App.tsx index 3415c2cf6..a73a1083c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { lockwalletOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack import { token } from '@joinmarket-webui/joinmarket-api-ts/jm' import { QueryClientProvider, useQuery } from '@tanstack/react-query' import type { TFunction } from 'i18next' -import { Loader2Icon } from 'lucide-react' import { ThemeProvider } from 'next-themes' import { useTranslation } from 'react-i18next' import { @@ -18,11 +17,11 @@ import { } from 'react-router-dom' import { toast } from 'sonner' import { useStore } from 'zustand' -import CreateWalletPage from '@/components/CreateWalletPage' import LoginPage from '@/components/LoginPage' import { LogsPage } from '@/components/LogsPage' import MainWalletPage from '@/components/MainWalletPage' import SwitchWalletPage from '@/components/SwitchWalletPage' +import CreateWalletPage from '@/components/create/CreateWalletPage' import { EarnPage } from '@/components/earn/EarnPage' import ErrorPage from '@/components/error/ErrorPage' import { Layout } from '@/components/layout/Layout' @@ -45,6 +44,7 @@ import { queryClient } from '@/lib/queryClient' import { setIntervalDebounced, walletDisplayName, type WalletFileName } from '@/lib/utils' import { authStore } from '@/store/authStore' import { jamSettingsStore } from '@/store/jamSettingsStore' +import { Spinner } from './components/ui/spinner' import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage' import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider' @@ -202,7 +202,7 @@ const Loading = () => { const { t } = useTranslation() return (
- + {t('global.loading')}
) diff --git a/src/components/CreateWalletPage.tsx b/src/components/CreateWalletPage.tsx deleted file mode 100644 index 489e70ff2..000000000 --- a/src/components/CreateWalletPage.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import React, { useState } from 'react' -import { type CreateWalletResponse, createwallet, session } from '@joinmarket-webui/joinmarket-api-ts/jm' -import { AlertCircleIcon, EyeIcon, EyeOffIcon, Loader2Icon, LockIcon, WalletIcon } from 'lucide-react' -import { Trans, useTranslation } from 'react-i18next' -import { Link, useNavigate } from 'react-router-dom' -import { toast } from 'sonner' -import { useStore } from 'zustand' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { routes } from '@/constants/routes' -import { useApiClient } from '@/hooks/useApiClient' -import { hashPassword } from '@/lib/hash' -import { walletDisplayName, JM_WALLET_FILE_EXTENSION, walletDisplayNameToFileName } from '@/lib/utils' -import type { WalletFileName } from '@/lib/utils' -import { authStore } from '@/store/authStore' -import { jmSessionStore } from '@/store/jmSessionStore' -import { SeedPhraseGrid } from './ui/jam/SeedPhraseGrid' -import PreventLeavingPageByMistake from './utils/PreventLeavingPageByMistake' - -const MAX_WALLET_NAME_LENGTH = 240 - JM_WALLET_FILE_EXTENSION.length -const validateWalletName = (input: string) => - input.length > 0 && input.length <= MAX_WALLET_NAME_LENGTH && /^[\w-]+$/.test(input) - -interface SeedPhraseContentProps { - seedphrase: string[] - onConfirm: () => Promise -} -const SeedPhraseContent = ({ seedphrase, onConfirm }: SeedPhraseContentProps) => { - return ( -
-
- -
- - - - - {/* TODO: i18n */} - Important: Write down this seed phrase and store it safely. It's the only way to recover your - wallet if you lose access. - - - - -
- ) -} - -type CreateWalletResponseWithHashedPassword = { - response: CreateWalletResponse - hashedPassword?: string -} - -const CreateWalletPage = () => { - const { t } = useTranslation() - const navigate = useNavigate() - const client = useApiClient() - const jmSessionInfo = useStore(jmSessionStore, (state) => state.state) - const { clear: clearAuthState, update: updateAuthState } = useStore(authStore, (state) => state) - const [walletName, setWalletName] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [showPassword, setShowPassword] = useState(false) - const [showConfirmPassword, setShowConfirmPassword] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [createWalletResponse, setCreateWalletResponse] = useState() - const [step, setStep] = useState<'create' | 'seed' | 'confirm'>('create') - - // TODO: use react-hook-form and yup schema - const handleCreateWallet = async (e: React.FormEvent) => { - e.preventDefault() - - const sanitizedWalletName = walletName.trim() - if (!validateWalletName(sanitizedWalletName)) { - toast.error(t('create_wallet.feedback_invalid_wallet_name')) - return - } - - if (password.length < 1) { - toast.error(t('create_wallet.feedback_invalid_password')) - return - } - - if (password !== confirmPassword) { - toast.error(t('create_wallet.feedback_invalid_password_confirm')) - return - } - - try { - setIsLoading(true) - - // Clear any existing local session - clearAuthState() - - // Check if there's an active session on the server - try { - const { data: sessionInfo } = await session({ client }) - if (sessionInfo?.session === true) { - console.warn('Active session detected:', sessionInfo) - toast.error( - `Cannot create wallet as "${walletDisplayName( - (sessionInfo?.wallet_name || 'Unknown') as WalletFileName, - )}" wallet is currently active.`, - { - description: ( - <> - Alternatively, you can{' '} - - log in with the existing wallet - {' '} - instead. - - ), - duration: 10_000, - }, - ) - return - } - } catch (sessionError) { - console.warn('Could not check session status:', sessionError) - // Continue anyway, wallet creation might still work - } - - const walletFileName = walletDisplayNameToFileName(walletName) - const { data: createData, error: createError } = await createwallet({ - client, - body: { - walletname: walletFileName, - password, - wallettype: 'sw-fb', - }, - }) - - if (createError) { - throw createError - } - - let hashedPassword: string | undefined = undefined - try { - hashedPassword = await hashPassword(password, createData?.walletname) - } catch (hashError) { - console.warn('Failed to hash password, continuing without hash verification:', hashError) - } - - if (createData?.seedphrase) { - setCreateWalletResponse({ - response: createData, - hashedPassword, - }) - setStep('seed') - } else { - throw new Error(/*TODO: i18n*/ 'No seedphrase returned') - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to create wallet' - toast.error(errorMessage) - } finally { - setIsLoading(false) - } - } - - const handleConfirmSeed = async ({ response, hashedPassword }: CreateWalletResponseWithHashedPassword) => { - updateAuthState({ - walletFileName: response.walletname as WalletFileName, - auth: { token: response.token, refresh_token: response.refresh_token }, // We'll need to unlock it properly later - hashed_password: hashedPassword, - }) - - await navigate(routes.home) - } - - // TODO: use react-hook-form and yup schema - const renderCreateForm = () => ( -
-
- - setWalletName(e.target.value)} - disabled={isLoading} - placeholder={t('create_wallet.placeholder_wallet_name')} - required - /> -
- -
- -
- - setPassword(e.target.value)} - disabled={isLoading} - placeholder={t('create_wallet.placeholder_password')} - maxLength={MAX_WALLET_NAME_LENGTH} - className="pr-10 pl-10" - required - /> - -
-
- -
- -
- - setConfirmPassword(e.target.value)} - disabled={isLoading} - placeholder={t('create_wallet.placeholder_password_confirm')} - className="pr-10 pl-10" - required - /> - -
-
- - -
- ) - - return ( -
- - -
- -
- - {step === 'create' && <>{t('create_wallet.title')}} - {/* TODO: i18n */ step === 'seed' && 'Save Your Seed Phrase'} - - - {/* TODO: i18n */ step === 'seed' && "This is your wallet's recovery phrase"} - -
- - - {step === 'seed' && ( - <> - - await handleConfirmSeed(createWalletResponse!)} - /> - - )} - {step === 'create' && ( - <> - {jmSessionInfo?.session === true ? ( - - - -

- - Currently walletName is active. You need to lock it first. - - Go back - - -

-
-
- ) : ( - <>{renderCreateForm()} - )} -
-

- {/* TODO: i18n */} - Already have a wallet?{' '} - -

-
- - )} -
-
-
- ) -} - -export default CreateWalletPage diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index a4233afb2..754327aa3 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { listwalletsOptions, unlockwalletMutation } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query' -import { AlertCircleIcon, EyeIcon, EyeOffIcon, Loader2Icon, LockIcon, RefreshCwIcon, WalletIcon } from 'lucide-react' +import { AlertCircleIcon, EyeIcon, EyeOffIcon, LockIcon, RefreshCwIcon, WalletIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -21,6 +21,7 @@ import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils' import type { WalletFileName } from '@/lib/utils' import { authStore, type AuthState } from '@/store/authStore' import { Badge } from './ui/badge' +import { Spinner } from './ui/spinner' const LoginFormSkeleton = () => { return ( @@ -132,7 +133,7 @@ const LoginForm = ({ wallets, isSubmitting, onSubmit, disabled }: LoginFormProps @@ -194,7 +191,7 @@ export const LogsPage = () => { return (
- + {t('global.loading')}
diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index 8c7e8cfa8..9003d0f07 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { DownloadIcon, InfoIcon, Loader2Icon, RefreshCwIcon, UploadIcon } from 'lucide-react' +import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -16,6 +16,7 @@ import { } from '@/context/JamWalletInfoContext' import type { WalletFileName } from '@/lib/utils' import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils' +import { Spinner } from './ui/spinner' import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay' interface MainWalletPageProps { @@ -50,14 +51,14 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) />
-

+

{walletNameTitle}

{isLoading ? (
- + {t('global.loading')}
) : ( @@ -111,7 +112,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{isFetching ? (
- + {t('global.loading')}
) : ( diff --git a/src/components/SwitchWalletPage.tsx b/src/components/SwitchWalletPage.tsx index 4334c665d..319efd492 100644 --- a/src/components/SwitchWalletPage.tsx +++ b/src/components/SwitchWalletPage.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { listwalletsOptions, lockwalletOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import type { ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm' import { useQuery } from '@tanstack/react-query' -import { AlertCircleIcon, Loader2Icon, LockIcon, RefreshCwIcon, UnlockIcon, WalletIcon } from 'lucide-react' +import { AlertCircleIcon, LockIcon, RefreshCwIcon, UnlockIcon, WalletIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { toast } from 'sonner' @@ -12,9 +12,10 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Skeleton } from '@/components/ui/skeleton' import { routes } from '@/constants/routes' import { useApiClient } from '@/hooks/useApiClient' -import { sortWallets, walletDisplayName } from '@/lib/utils' +import { shortenStringMiddle, sortWallets, walletDisplayName } from '@/lib/utils' import type { WalletFileName } from '@/lib/utils' import { authStore } from '@/store/authStore' +import { Spinner } from './ui/spinner' const SwitchWalletFormSkeleton = () => { return ( @@ -102,7 +103,7 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => {
{listWalletsFetching ? ( - + ) : ( await listWalletsRefetch()} /> )} @@ -155,10 +156,12 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => { wallet === walletFileName ? 'bg-primary/5 border-primary/20' : 'bg-muted/30' }`} > -
+
- {walletDisplayName(wallet)} + + {shortenStringMiddle(walletDisplayName(wallet) ?? '...', 63)} +
{wallet === walletFileName && ( @@ -189,7 +192,7 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => { > {lockCurrentWallet.isFetching ? ( <> - + {t('settings.button_locking_wallet')} ) : currentWalletLocked ? ( diff --git a/src/components/create/CreateStepConfirm.tsx b/src/components/create/CreateStepConfirm.tsx new file mode 100644 index 000000000..5bbecea47 --- /dev/null +++ b/src/components/create/CreateStepConfirm.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' +import { AlertCircleIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import type { WalletFileName } from '@/lib/utils' +import { MaskedText } from '../ui/jam/MaskedText' +import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid' +import { Switch } from '../ui/switch' + +interface CreateStepConfirmProps { + walletFileName: WalletFileName + password: string + seedphrase: string[] + onConfirm: () => Promise +} + +export const CreateStepConfirm = ({ walletFileName, password, seedphrase, onConfirm }: CreateStepConfirmProps) => { + const [revealSensitiveInfo, setRevealSensitiveInfo] = useState({ checked: false, dirty: false }) + const [backupConfirmed, setBackupConfirmed] = useState(false) + const { t } = useTranslation() + + useEffect(() => { + if (backupConfirmed) return + + const toastId = toast.message( + + + + {/* TODO: i18n */} + Save Your Seed Phrase + + + {/* TODO: change i18n key ("alert_description") */} + {t('create_wallet.subtitle_wallet_created')} + + , + { + duration: Infinity, + unstyled: true, + }, + ) + + return () => { + toast.dismiss(toastId) + } + }, [backupConfirmed, t]) + + return ( +
+
+
+ + {walletFileName} +
+
+ + + {password} + +
+
+ +
+ +
+
+
+ +
+
+ setRevealSensitiveInfo((it) => ({ ...it, checked, dirty: true }))} + /> + +
+ +
+ setBackupConfirmed(checked)} + disabled={!revealSensitiveInfo.dirty} + /> + +
+
+ + +
+ ) +} diff --git a/src/components/create/CreateStepDetailsInput.tsx b/src/components/create/CreateStepDetailsInput.tsx new file mode 100644 index 000000000..08bd1c379 --- /dev/null +++ b/src/components/create/CreateStepDetailsInput.tsx @@ -0,0 +1,55 @@ +import type { ComponentProps } from 'react' +import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm' +import { AlertCircleIcon } from 'lucide-react' +import { Trans } from 'react-i18next' +import { Link } from 'react-router-dom' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { routes } from '@/constants/routes' +import { walletDisplayName } from '@/lib/utils' +import type { WalletFileName } from '@/lib/utils' +import { CreateWalletForm } from './CreateWalletForm' + +type CreateStepDetailsInputProps = ComponentProps & { + sessionInfo: SessionResponse | undefined +} + +export const CreateStepDetailsInput = ({ onSubmit, isSubmitting, sessionInfo }: CreateStepDetailsInputProps) => { + return ( + <> + {sessionInfo?.session === true ? ( + + + +

+ + Currently walletName is active. You need to lock it first. + + Go back + + +

+
+
+ ) : ( + + )} +
+

+ {/* TODO: i18n */} + Already have a wallet?{' '} + +

+
+ + ) +} diff --git a/src/components/create/CreateWalletForm.tsx b/src/components/create/CreateWalletForm.tsx new file mode 100644 index 000000000..b67200bde --- /dev/null +++ b/src/components/create/CreateWalletForm.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { EyeIcon, EyeOffIcon, LockIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { MAX_WALLET_NAME_LENGTH } from '@/constants/jam' +import { Spinner } from '../ui/spinner' + +type CreateWalletFormProps = { + onSubmit: (args: { walletName: string; password: string; confirmPassword: string }) => Promise + isSubmitting: boolean +} + +// TODO: use react-hook-form and yup schema +export const CreateWalletForm = ({ onSubmit, isSubmitting }: CreateWalletFormProps) => { + const { t } = useTranslation() + const [walletName, setWalletName] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + + return ( +
{ + e.preventDefault() + onSubmit({ + walletName, + password, + confirmPassword, + }) + }} + className="space-y-4" + noValidate + > +
+ + setWalletName(e.target.value)} + disabled={isSubmitting} + placeholder={t('create_wallet.placeholder_wallet_name')} + required + /> +
+ +
+ +
+ + setPassword(e.target.value)} + disabled={isSubmitting} + placeholder={t('create_wallet.placeholder_password')} + maxLength={MAX_WALLET_NAME_LENGTH} + className="pr-10 pl-10" + required + /> + +
+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isSubmitting} + placeholder={t('create_wallet.placeholder_password_confirm')} + className="pr-10 pl-10" + required + /> + +
+
+ + +
+ ) +} diff --git a/src/components/create/CreateWalletPage.tsx b/src/components/create/CreateWalletPage.tsx new file mode 100644 index 000000000..b90423526 --- /dev/null +++ b/src/components/create/CreateWalletPage.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { type CreateWalletResponse, createwallet, session } from '@joinmarket-webui/joinmarket-api-ts/jm' +import { CircleCheckBigIcon, WalletIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { toast } from 'sonner' +import { useStore } from 'zustand' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { MAX_WALLET_NAME_LENGTH } from '@/constants/jam' +import { routes } from '@/constants/routes' +import { useApiClient } from '@/hooks/useApiClient' +import { hashPassword } from '@/lib/hash' +import { walletDisplayName, walletDisplayNameToFileName } from '@/lib/utils' +import type { WalletFileName } from '@/lib/utils' +import { authStore } from '@/store/authStore' +import { jmSessionStore } from '@/store/jmSessionStore' +import PreventLeavingPageByMistake from '../utils/PreventLeavingPageByMistake' +import { CreateStepConfirm } from './CreateStepConfirm' +import { CreateStepDetailsInput } from './CreateStepDetailsInput' + +const validateWalletName = (input: string) => + input.length > 0 && input.length <= MAX_WALLET_NAME_LENGTH && /^[\w-]+$/.test(input) + +type WalletFormValues = { walletName: string; password: string; confirmPassword: string } + +type CreateWalletSuccessInfo = { + values: WalletFormValues + response: CreateWalletResponse + hashedPassword?: string +} + +const CreateWalletPage = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const client = useApiClient() + const jmSessionInfo = useStore(jmSessionStore, (state) => state.state) + const { clear: clearAuthState, update: updateAuthState } = useStore(authStore, (state) => state) + const [isCreating, setIsCreating] = useState(false) + const [createWalletSuccessInfo, setCreateWalletSuccessInfo] = useState() + const [step, setStep] = useState<'create' | 'seed' | 'confirm'>('create') + + // TODO: use react-hook-form and yup schema + const handleCreateWallet = async ({ walletName, password, confirmPassword }: WalletFormValues) => { + const sanitizedWalletName = walletName.trim() + if (!validateWalletName(sanitizedWalletName)) { + toast.error(t('create_wallet.feedback_invalid_wallet_name')) + return + } + + if (password.length < 1) { + toast.error(t('create_wallet.feedback_invalid_password')) + return + } + + if (password !== confirmPassword) { + toast.error(t('create_wallet.feedback_invalid_password_confirm')) + return + } + + const durationHintToastId = toast.loading(t('create_wallet.hint_duration_text'), { + id: 'alert-wallet-create-creating-duration-hint', + duration: Infinity, + position: 'top-center', + }) + try { + setIsCreating(true) + + // Clear any existing local session + clearAuthState() + + // Check if there's an active session on the server + try { + const { data: sessionInfo } = await session({ client }) + if (sessionInfo?.session === true) { + console.warn('Active session detected:', sessionInfo) + toast.error( + `Cannot create wallet as "${walletDisplayName( + (sessionInfo?.wallet_name || 'Unknown') as WalletFileName, + )}" wallet is currently active.`, + { + description: ( + <> + Alternatively, you can{' '} + + log in with the existing wallet + {' '} + instead. + + ), + duration: 10_000, + }, + ) + return + } + } catch (sessionError) { + console.warn('Could not check session status:', sessionError) + // Continue anyway, wallet creation might still work + } + + const walletFileName = walletDisplayNameToFileName(walletName) + const { data: createData, error: createError } = await createwallet({ + client, + body: { + walletname: walletFileName, + password, + wallettype: 'sw-fb', + }, + }) + + if (createError) { + throw createError + } + + let hashedPassword: string | undefined = undefined + try { + hashedPassword = await hashPassword(password, createData.walletname) + } catch (hashError) { + console.warn('Failed to hash password, continuing without hash verification:', hashError) + } + + setCreateWalletSuccessInfo({ + values: { walletName, password, confirmPassword }, + response: createData, + hashedPassword, + }) + setStep('seed') + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to create wallet' + toast.error(errorMessage) + } finally { + setIsCreating(false) + toast.dismiss(durationHintToastId) + } + } + + const handleConfirmSeed = async ({ response, hashedPassword }: CreateWalletSuccessInfo) => { + updateAuthState({ + walletFileName: response.walletname as WalletFileName, + auth: { token: response.token, refresh_token: response.refresh_token }, // We'll need to unlock it properly later + hashed_password: hashedPassword, + }) + + await navigate(routes.home) + } + + return ( +
+ + +
+ {step === 'create' && } + {step === 'seed' && } +
+ + {step === 'create' && t('create_wallet.title')} + {step === 'seed' && t('create_wallet.title_wallet_created')} + +
+ + + {step === 'seed' && ( + <> + + await handleConfirmSeed(createWalletSuccessInfo!)} + /> + + )} + {step === 'create' && ( + + )} + +
+
+ ) +} + +export default CreateWalletPage diff --git a/src/components/earn/EarnForm.tsx b/src/components/earn/EarnForm.tsx index a79340c24..6763965f2 100644 --- a/src/components/earn/EarnForm.tsx +++ b/src/components/earn/EarnForm.tsx @@ -1,6 +1,6 @@ import { useId } from 'react' import { yupResolver } from '@hookform/resolvers/yup' -import { HandshakeIcon, Loader2Icon, PercentIcon } from 'lucide-react' +import { HandshakeIcon, PercentIcon } from 'lucide-react' import { useForm, useWatch } from 'react-hook-form' import type { Resolver, SubmitHandler } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -15,6 +15,7 @@ import * as JAM from '@/constants/jam' import type { OfferType } from '@/constants/jm' import { cn, factorToPercentage } from '@/lib/utils' import type { AmountSats } from '@/types/global' +import { Spinner } from '../ui/spinner' const FieldPrefixSatSymbol = ( {isSubmitting || isWaitingMakerStart ? ( <> - + {t('earn.text_starting')} ) : ( diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx index 0b376bcc5..28be0e0c3 100644 --- a/src/components/earn/EarnPage.tsx +++ b/src/components/earn/EarnPage.tsx @@ -26,6 +26,7 @@ import type { WalletFileName } from '@/lib/utils' import { jamSettingsStore } from '@/store/jamSettingsStore' import { jmSessionStore } from '@/store/jmSessionStore' import type { Milliseconds } from '@/types/global' +import { Spinner } from '../ui/spinner' import { EarnForm, type EarnFormValues } from './EarnForm' import { FidelityBondCard } from './FidelityBondCard' import { OfferCard } from './OfferCard' @@ -152,7 +153,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { return (
- + {t('global.loading')}
@@ -211,7 +212,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
@@ -73,10 +73,12 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi {t('earn.current.text_cjfee')} {isRelativeOffer(value?.ordertype || '') ? ( - <>{factorToPercentage(parseFloat(value?.cjfee || '') || 0)}% + {factorToPercentage(parseFloat(value?.cjfee || '') || 0)}% ) : ( <> - {formatAmount(parseInt(String(value?.cjfee || '0'), 10))} + + {formatAmount(parseInt(String(value?.cjfee || '0'), 10))} + {currencySymbol('sm')} )} @@ -88,7 +90,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
{t('earn.current.text_minsize')} - {formatAmount(parseInt(String(value?.minsize || '0'), 10))} + + {formatAmount(parseInt(String(value?.minsize || '0'), 10))} + {currencySymbol('sm')}
@@ -98,7 +102,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
{t('earn.current.text_maxsize')} - {formatAmount(parseInt(String(value?.maxsize || '0'), 10))} + + {formatAmount(parseInt(String(value?.maxsize || '0'), 10))} + {currencySymbol('sm')}
@@ -108,7 +114,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
{t('earn.current.text_txfee')} - {formatAmount(parseInt(String(value?.txfee || '0'), 10))} + + {formatAmount(parseInt(String(value?.txfee || '0'), 10))} + {currencySymbol('sm')}
diff --git a/src/components/layout/AppNavbar.tsx b/src/components/layout/AppNavbar.tsx index 05ed23ead..f34c04b15 100644 --- a/src/components/layout/AppNavbar.tsx +++ b/src/components/layout/AppNavbar.tsx @@ -1,15 +1,7 @@ import type { PropsWithChildren } from 'react' import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm' import type { TFunction } from 'i18next' -import { - Loader2Icon, - LockKeyholeIcon, - LogOutIcon, - PackageSearchIcon, - SettingsIcon, - ShuffleIcon, - WalletIcon, -} from 'lucide-react' +import { LockKeyholeIcon, LogOutIcon, PackageSearchIcon, SettingsIcon, ShuffleIcon, WalletIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Link, useNavigate, type NavigateFunction } from 'react-router-dom' import { DevBadge } from '@/components/dev/DevBadge' @@ -22,6 +14,7 @@ import { routes } from '@/constants/routes' import type { RescanInfo } from '@/context/JamSessionInfoContext' import { cn, shortenStringMiddle } from '@/lib/utils' import type { AmountSats } from '@/types/global' +import { Spinner } from '../ui/spinner' const WithActivityIndicator = ({ active, children }: PropsWithChildren<{ active: boolean }>) => { return ( @@ -63,7 +56,7 @@ const WalletPreview = ({
{isLoading ? ( - + ) : ( )} diff --git a/src/components/orderbook/OrderbookContent.tsx b/src/components/orderbook/OrderbookContent.tsx index d24b66d3c..1bab73622 100644 --- a/src/components/orderbook/OrderbookContent.tsx +++ b/src/components/orderbook/OrderbookContent.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import type { RowModel } from '@tanstack/react-table' import type { i18n } from 'i18next' -import { ChevronDownIcon, RefreshCwIcon, PlusIcon, AlertCircleIcon, Loader2Icon } from 'lucide-react' +import { ChevronDownIcon, RefreshCwIcon, PlusIcon, AlertCircleIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useStore } from 'zustand' import { DevBadge } from '@/components/dev/DevBadge' @@ -26,6 +26,7 @@ import { withQueryDelay } from '@/lib/queryClient' import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer, pseudoRandomInteger, time } from '@/lib/utils' import { jamSettingsStore } from '@/store/jamSettingsStore' import { jmSessionStore } from '@/store/jmSessionStore' +import { Spinner } from '../ui/spinner' import { OrderbookTable, type OrderTableEntry } from './OrderbookTable' const offerToTableEntry = ( @@ -318,7 +319,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps) {isLoadingInitially ? (
- + {t('global.loading')}
diff --git a/src/components/orderbook/OrderbookOverlay.tsx b/src/components/orderbook/OrderbookOverlay.tsx index 095fa1eea..b33011255 100644 --- a/src/components/orderbook/OrderbookOverlay.tsx +++ b/src/components/orderbook/OrderbookOverlay.tsx @@ -15,14 +15,14 @@ export function OrderbookOverlay({ open, onOpenChange }: OrderbookOverlayProps) return ( onOpenChange(false)}> - +
- +
diff --git a/src/components/orderbook/OrderbookTable.tsx b/src/components/orderbook/OrderbookTable.tsx index 8463c7ecc..96c6a837a 100644 --- a/src/components/orderbook/OrderbookTable.tsx +++ b/src/components/orderbook/OrderbookTable.tsx @@ -137,14 +137,14 @@ export const OrderbookTable = ({ const bid = Number(b.original.orderId) return aid - bid }, - cell: (info) => {info.getValue()}, + cell: (info) => {info.getValue()}, meta: { alphabetic: true, } as OrderTableColumnMeta, }), columnHelper.accessor('orderId', { header: () => t('orderbook.table.heading_order_id'), - cell: (info) => info.getValue(), + cell: (info) => {info.getValue()}, meta: { numeric: true, } as OrderTableColumnMeta, diff --git a/src/components/send/SendPage.tsx b/src/components/send/SendPage.tsx index db0692d4c..169f14061 100644 --- a/src/components/send/SendPage.tsx +++ b/src/components/send/SendPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { AlertTriangleIcon, Loader2Icon } from 'lucide-react' +import { AlertTriangleIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { FeeLimitDialog } from '@/components/settings/FeeLimitDialog' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -7,6 +7,7 @@ import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert' import PageTitle from '@/components/ui/jam/PageTitle' import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation' import type { WalletFileName } from '@/lib/utils' +import { Spinner } from '../ui/spinner' interface SendPageProps { walletFileName: WalletFileName @@ -22,7 +23,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { return (
- + {t('global.loading')}
diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 0bac33ba2..57cd66d5a 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -3,16 +3,7 @@ import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/re import { mnemonicToSeed } from '@scure/bip39' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Network } from 'bitcoin-address-validation' -import { - EyeIcon, - EyeOffIcon, - AlertTriangleIcon, - ClockIcon, - Loader2Icon, - CopyIcon, - CheckIcon, - AlertCircleIcon, -} from 'lucide-react' +import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, CopyIcon, CheckIcon, AlertCircleIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' @@ -40,6 +31,7 @@ import { Alert, AlertDescription, AlertTitle } from '../ui/alert' import { Badge } from '../ui/badge' import { buttonVariants } from '../ui/button-variants' import { CopyButton } from '../ui/jam/CopyButton' +import { Spinner } from '../ui/spinner' const HD_PATH_PURPOSE: number = 84 @@ -120,75 +112,64 @@ const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { {values.map((account, index) => { const accountLabel = t('settings.xpubs_modal.label_account', { - accountIndex: account.accountIndex, + accountIndex: `#${account.accountIndex}`, }) return ( - - - - {account.accountName} - - ({accountLabel}) + + + {account.accountName} + {accountLabel} - -
-
- - {account.xpubs.map((xpub, index) => { - const accountNameAndLabel = `${account.accountName} (${accountLabel})` - return ( -
- - {xpub.xpub} - - } - successText={} - title={t('settings.xpubs_modal.button_copy_title', { - account: accountNameAndLabel, - })} - aria-label={t('settings.xpubs_modal.button_copy_title', { - account: accountNameAndLabel, - })} - onSuccess={() => - toast.success( - t('settings.xpubs_modal.alert_success_account_xpub_copied_message', { - account: accountNameAndLabel, - }), - ) - } - onError={(e) => - toast.error( - t('global.errors.error_copy_to_clipboard_failed', { - reason: - (e instanceof Error ? e.message : undefined) || t('global.errors.reason_unknown'), - }), - ) - } - /> -
- ) - })} + +
+
+ {account.xpubs.length === 0 ? undefined : {account.xpubs[0].network}} +
+ + {account.xpubs.map((xpub, index) => { + const accountNameAndLabel = `${account.accountName} (${accountLabel})` + return ( +
+
{xpub.xpub}
+ } + successText={} + title={t('settings.xpubs_modal.button_copy_title', { + account: accountNameAndLabel, + })} + aria-label={t('settings.xpubs_modal.button_copy_title', { + account: accountNameAndLabel, + })} + onSuccess={() => + toast.success( + t('settings.xpubs_modal.alert_success_account_xpub_copied_message', { + account: accountNameAndLabel, + }), + ) + } + onError={(e) => + toast.error( + t('global.errors.error_copy_to_clipboard_failed', { + reason: (e instanceof Error ? e.message : undefined) || t('global.errors.reason_unknown'), + }), + ) + } + /> +
+ ) + })} ) @@ -378,9 +359,14 @@ export const AccountXpubsDialog = ({ open, onOpenChange, walletFileName, hashedP {t('global.cancel')} @@ -395,10 +381,10 @@ export const AccountXpubsDialog = ({ open, onOpenChange, walletFileName, hashedP
{!accountXpubs.error && ( -
+
{isFetching ? (
- + {t('global.loading')}
) : accountXpubs.data && accountXpubs.data.length > 0 ? ( @@ -408,7 +394,7 @@ export const AccountXpubsDialog = ({ open, onOpenChange, walletFileName, hashedP {t('settings.xpubs_modal.text_info_title')} {t('settings.xpubs_modal.text_info_message')} -
+
diff --git a/src/components/settings/FeeLimitDialog.tsx b/src/components/settings/FeeLimitDialog.tsx index d664977cd..533c0294c 100644 --- a/src/components/settings/FeeLimitDialog.tsx +++ b/src/components/settings/FeeLimitDialog.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useEffect, type ComponentProps } from 'react' import { configsettingMutation } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { useMutation } from '@tanstack/react-query' import { cx } from 'class-variance-authority' -import { Loader2Icon } from 'lucide-react' import { useTranslation, Trans } from 'react-i18next' import { toast } from 'sonner' import { useStore } from 'zustand' @@ -26,6 +25,7 @@ import { factorToPercentage } from '@/lib/utils' import type { WalletFileName } from '@/lib/utils' import { jamSettingsStore } from '@/store/jamSettingsStore' import type { WithRequiredProperty } from '@/types/global' +import { Spinner } from '../ui/spinner' import { CollaboratorFeesForm, type CollaboratorFeesFormRef } from './CollaboratorFeesForm' import { MiningFeesForm, type MiningFeesFormRef } from './MiningFeesForm' @@ -214,8 +214,8 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName }: FeeLimitD {isLoadingConfig ? ( -
- +
+ {t('global.loading')}
) : ( @@ -255,8 +255,8 @@ export const FeeLimitDialog = ({ open, onOpenChange, walletFileName }: FeeLimitD {isLoadingConfig ? ( -
- +
+ {t('global.loading')}
) : ( diff --git a/src/components/settings/SeedPhraseDialog.tsx b/src/components/settings/SeedPhraseDialog.tsx index 14db97159..d9d2c569a 100644 --- a/src/components/settings/SeedPhraseDialog.tsx +++ b/src/components/settings/SeedPhraseDialog.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, type ComponentProps } from 'react' import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query' import { cx } from 'class-variance-authority' -import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon } from 'lucide-react' +import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -22,6 +22,7 @@ import { hashPassword } from '@/lib/hash' import type { WalletFileName } from '@/lib/utils' import type { SeedPhrase, WithRequiredProperty } from '@/types/global' import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid' +import { Spinner } from '../ui/spinner' import { Switch } from '../ui/switch' type SeedPhraseDialogProps = WithRequiredProperty< @@ -184,7 +185,7 @@ export const SeedPhraseDialog = ({ open, onOpenChange, walletFileName, hashedPas ) diff --git a/src/components/ui/jam/MaskedText.tsx b/src/components/ui/jam/MaskedText.tsx new file mode 100644 index 000000000..d1b84f355 --- /dev/null +++ b/src/components/ui/jam/MaskedText.tsx @@ -0,0 +1,25 @@ +import type { PropsWithChildren } from 'react' +import { cn } from '@/lib/utils' + +interface MaskedTextProps { + className?: string + masked: boolean + maskedText?: React.ReactNode | string +} + +export const MaskedText = ({ + children, + className, + masked, + maskedText = 'masked', +}: PropsWithChildren) => { + return ( + + {masked ? maskedText : children} + + ) +} diff --git a/src/components/ui/jam/SeedPhraseGrid.tsx b/src/components/ui/jam/SeedPhraseGrid.tsx index e7fb97902..5e1bd2c45 100644 --- a/src/components/ui/jam/SeedPhraseGrid.tsx +++ b/src/components/ui/jam/SeedPhraseGrid.tsx @@ -1,24 +1,26 @@ +import type { ComponentProps } from 'react' import { cn } from '@/lib/utils' import type { SeedPhrase } from '@/types/global' +import { MaskedText } from './MaskedText' -interface SeedPhraseGridProps { +type SeedPhraseGridProps = ComponentProps & { value: SeedPhrase className?: string - blurred: boolean - blurredText?: string } -export const SeedPhraseGrid = ({ value, className, blurred, blurredText = 'random' }: SeedPhraseGridProps) => { +export const SeedPhraseGrid = ({ value, className, masked, maskedText }: SeedPhraseGridProps) => { return ( -
- {value - .map((it) => (!blurred ? it : blurredText)) - .map((word, index) => ( -
- {index + 1}. - {word} -
- ))} +
+ {value.map((word, index) => ( +
+ + {index + 1}. + + + {word} + +
+ ))}
) } diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 000000000..b36a07ed4 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,17 @@ +import { Loader2Icon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' + +function Spinner({ className, ...props }: React.ComponentProps<'svg'>) { + const { t } = useTranslation() + return ( + + ) +} + +export { Spinner } diff --git a/src/components/wallet/AccountDetailsTabContent.tsx b/src/components/wallet/AccountDetailsTabContent.tsx index be6a119e4..a0f707b89 100644 --- a/src/components/wallet/AccountDetailsTabContent.tsx +++ b/src/components/wallet/AccountDetailsTabContent.tsx @@ -88,27 +88,23 @@ export const AccountDetailsTabContent = ({ value }: AccountDetailsTabContentProp const defaultValue = displayBranches.length > 0 ? [String(displayBranches[0].index)] : undefined return ( -
- - {displayBranches.map(({ branch, index }) => { - const typeTitle = toTypeHeading(branch.type, t) - return ( - - -
- - {typeTitle} - - {branch.derivation} -
-
- - - -
- ) - })} -
-
+ + {displayBranches.map(({ branch, index }) => { + const typeTitle = toTypeHeading(branch.type, t) + return ( + + +
+ {typeTitle} + {branch.derivation} +
+
+ + + +
+ ) + })} +
) } diff --git a/src/components/wallet/BranchEntryTable.tsx b/src/components/wallet/BranchEntryTable.tsx index 04d3c19c3..4e9d4a095 100644 --- a/src/components/wallet/BranchEntryTable.tsx +++ b/src/components/wallet/BranchEntryTable.tsx @@ -137,7 +137,7 @@ export const BranchEntryTable = ({ // tie-break using derivationIndex return a.original.derivationIndex - b.original.derivationIndex }, - cell: (info) => {info.getValue()}, + cell: (info) => {info.getValue()}, meta: { alphabetic: true, }, diff --git a/src/components/wallet/JarUtxosTable.tsx b/src/components/wallet/JarUtxosTable.tsx index cbde764d6..3cc3604ae 100644 --- a/src/components/wallet/JarUtxosTable.tsx +++ b/src/components/wallet/JarUtxosTable.tsx @@ -128,7 +128,7 @@ export const JarUtxosTable = ({ const bid = Number(b.original.confirmations) return aid - bid }, - cell: (info) => {info.getValue()}, + cell: (info) => {info.getValue()}, meta: { alphabetic: true, } as UtxoTableColumnMeta, diff --git a/src/components/wallet/WalletJarsDetailsContent.tsx b/src/components/wallet/WalletJarsDetailsContent.tsx index 53f01244d..082f9c41b 100644 --- a/src/components/wallet/WalletJarsDetailsContent.tsx +++ b/src/components/wallet/WalletJarsDetailsContent.tsx @@ -92,11 +92,7 @@ interface DetailsContentProps { } export const DetailsContent = ({ enabled: _enabled, account }: DetailsContentProps) => { - return ( - <> - - - ) + return } interface WalletJarsDetailsContentProps { diff --git a/src/components/wallet/WalletJarsDetailsOverlay.tsx b/src/components/wallet/WalletJarsDetailsOverlay.tsx index bdcb4875c..992f9b10a 100644 --- a/src/components/wallet/WalletJarsDetailsOverlay.tsx +++ b/src/components/wallet/WalletJarsDetailsOverlay.tsx @@ -16,7 +16,7 @@ export function WalletJarsDetailsOverlay({ open, onOpenChange, selectJarIndex }: return ( onOpenChange(false)}> - + @@ -25,7 +25,7 @@ export function WalletJarsDetailsOverlay({ open, onOpenChange, selectJarIndex }:
diff --git a/src/constants/jam.ts b/src/constants/jam.ts index a35a4951f..73d629f62 100644 --- a/src/constants/jam.ts +++ b/src/constants/jam.ts @@ -1,12 +1,14 @@ import { percentageToFactor, toSemVer } from '@/lib/utils' import type { AmountSats, Milliseconds } from '@/types/global' import { version as packageInfoVersion } from '../../package.json' -import { JM_API_AUTH_TOKEN_EXPIRY, JM_DUST_THRESHOLD } from './jm' +import { JM_API_AUTH_TOKEN_EXPIRY, JM_DUST_THRESHOLD, JM_WALLET_FILE_EXTENSION } from './jm' export const APP_DISPLAY_VERSION = (() => { return toSemVer(packageInfoVersion) })() +export const MAX_WALLET_NAME_LENGTH = 240 - JM_WALLET_FILE_EXTENSION.length + export const TX_FEES_FACTOR_MIN = 0 // 0% /** diff --git a/src/constants/jm.ts b/src/constants/jm.ts index f7ea7e25c..6f33cef96 100644 --- a/src/constants/jm.ts +++ b/src/constants/jm.ts @@ -1,3 +1,5 @@ +export const JM_WALLET_FILE_EXTENSION = '.jmdat' + export const JM_API_AUTH_TOKEN_EXPIRY = parseInt(import.meta.env.VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS, 10) * 1_000 export const JM_MAX_SWEEP_FEE_CHANGE_DEFAULT = 0.8 diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 4562f0ca9..37e7d80ca 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -151,7 +151,7 @@ "error_creating_failed": "Error while creating the wallet. Reason: {{ reason }}", "alert_confirmation_failed": "Wallet confirmation failed.", "confirmation_label_wallet_name": "Wallet Name", - "confirmation_label_password": "Password", + "confirmation_label_password": "Wallet Password", "confirmation_toggle_reveal_info": "Reveal sensitive information", "confirmation_toggle_info_written_down": "I've written down the information above.", "confirmation_button_fund_wallet": "Fund Wallet", diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 60ea2acf4..7c21d7663 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { JM_WALLET_FILE_EXTENSION } from '@/constants/jm' import { cn, walletDisplayName, - JM_WALLET_FILE_EXTENSION, setIntervalDebounced, satsToBtc, btcToSats, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5e29bbf72..97e76e2d8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' -import type { OfferType } from '@/constants/jm' +import { JM_WALLET_FILE_EXTENSION, type OfferType } from '@/constants/jm' import type { Milliseconds, SeedPhrase } from '@/types/global' const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…` @@ -9,8 +9,6 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export const JM_WALLET_FILE_EXTENSION = '.jmdat' - export type Unit = 'BTC' | 'sats' export const BTC: Unit = 'BTC' diff --git a/src/stories/Accordion.stories.tsx b/src/stories/Accordion.stories.tsx index a7153c250..6c6fd7aca 100644 --- a/src/stories/Accordion.stories.tsx +++ b/src/stories/Accordion.stories.tsx @@ -70,21 +70,29 @@ export const DefaultOpen: Story = { ), } -export const CustomStyling: Story = { +export const CustomStyling1: Story = { render: () => ( - - - Custom styled accordion - + + + +
+ Apricot + Account #0 +
+
+ This accordion has custom styling applied to demonstrate the flexibility of the component.
- - - Another custom section + + +
+ Blueberry + Account #1 +
- - You can customize each accordion item independently. + + This accordion has custom styling applied to demonstrate the flexibility of the component.
diff --git a/src/stories/Spinner.stories.tsx b/src/stories/Spinner.stories.tsx new file mode 100644 index 000000000..54390fe41 --- /dev/null +++ b/src/stories/Spinner.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Label } from '@/components/ui/label' +import { Spinner } from '@/components/ui/spinner' + +const meta: Meta = { + title: 'Core/Spinner', + component: Spinner, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: 'An indicator that can be used to show a loading state.', + }, + }, + }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + children: 'Label text', + }, +} + +export const Sizes: Story = { + render: () => ( +
+ + + + + + +
+ ), +} + +export const WithLabel: Story = { + render: () => ( +
+ + +
+ ), +} + +export const WithStrokeWith: Story = { + render: () => , +} diff --git a/src/stories/jam/AppFooter.stories.tsx b/src/stories/jam/AppFooter.stories.tsx index 173e6ac67..7c914dd2b 100644 --- a/src/stories/jam/AppFooter.stories.tsx +++ b/src/stories/jam/AppFooter.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { MemoryRouter } from 'react-router-dom' import { AppFooter } from '@/components/layout/AppFooter' import { APP_DISPLAY_VERSION } from '@/constants/jam' import { toSemVer } from '@/lib/utils' @@ -8,13 +7,6 @@ const meta: Meta = { title: 'Layout/AppFooter', component: AppFooter, tags: ['autodocs'], - decorators: [ - (Story) => ( - - - - ), - ], } export default meta diff --git a/src/stories/jam/AppNavbar.stories.tsx b/src/stories/jam/AppNavbar.stories.tsx index f97061755..ab9205de7 100644 --- a/src/stories/jam/AppNavbar.stories.tsx +++ b/src/stories/jam/AppNavbar.stories.tsx @@ -1,6 +1,5 @@ import type { ComponentProps } from 'react' import type { Meta, StoryObj } from '@storybook/react-vite' -import { MemoryRouter } from 'react-router-dom' import { AppNavbar } from '@/components/layout/AppNavbar' import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol' @@ -8,13 +7,6 @@ const meta: Meta = { title: 'Layout/AppNavbar', component: AppNavbar, tags: ['autodocs'], - decorators: [ - (Story) => ( - - - - ), - ], } export default meta diff --git a/src/stories/jam/SeedPhraseGrid.stories.tsx b/src/stories/jam/SeedPhraseGrid.stories.tsx index d2f9fcdf8..ed1b23273 100644 --- a/src/stories/jam/SeedPhraseGrid.stories.tsx +++ b/src/stories/jam/SeedPhraseGrid.stories.tsx @@ -14,9 +14,15 @@ const DUMMY_SEED_PHRASE: string[] = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split(/\s+/) export const Default: Story = { - render: () => , + render: () => , } export const Revealed: Story = { - render: () => , + render: () => , +} + +export const Responsive: Story = { + render: () => ( + + ), } diff --git a/src/stories/jam/pages/create/CreateStepConfirm.stories.tsx b/src/stories/jam/pages/create/CreateStepConfirm.stories.tsx new file mode 100644 index 000000000..73eb2895f --- /dev/null +++ b/src/stories/jam/pages/create/CreateStepConfirm.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { CreateStepConfirm } from '@/components/create/CreateStepConfirm' +import { DUMMY_SEED_PHRASE } from '@/lib/utils' + +const meta: Meta = { + title: 'Page/Create/CreateStepConfirm', + component: CreateStepConfirm, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + walletFileName: 'Satoshi.jmdat', + password: 'correct horse battery staple', + seedphrase: DUMMY_SEED_PHRASE, + onConfirm: async () => alert('Confirm clicked!'), + }, +} diff --git a/src/stories/jam/pages/create/CreateStepDetailsInput.stories.tsx b/src/stories/jam/pages/create/CreateStepDetailsInput.stories.tsx new file mode 100644 index 000000000..d23629f3e --- /dev/null +++ b/src/stories/jam/pages/create/CreateStepDetailsInput.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { CreateStepDetailsInput } from '@/components/create/CreateStepDetailsInput' + +const meta: Meta = { + title: 'Page/Create/CreateStepDetailsInput', + component: CreateStepDetailsInput, + tags: ['autodocs'], +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + onSubmit: async () => alert('Submit clicked!'), + isSubmitting: false, + sessionInfo: undefined, + }, +} + +export const WithActiveSession: Story = { + args: { + onSubmit: async () => alert('Submit clicked!'), + isSubmitting: false, + sessionInfo: { + session: true, + wallet_name: 'Satoshi.jmdat', + maker_running: false, + coinjoin_in_process: false, + rescanning: false, + }, + }, +} diff --git a/vite.config.ts b/vite.config.ts index 182cb723f..86bda4707 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -68,6 +68,11 @@ export default defineConfig((): UserConfig => { include: ['**/*.test.{ts,tsx}'], exclude: ['node_modules', '.storybook'], }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }, ], },