mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-19 13:18:30 +02:00
[v2] improve user onboarding UX on login (#1081)
This commit is contained in:
parent
8642ec54c4
commit
58bb4583f0
8 changed files with 557 additions and 89 deletions
|
|
@ -78,7 +78,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-center gap-4">
|
||||
<div className="mt-2 flex w-full justify-center gap-4" data-tour-id="wallet-actions">
|
||||
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
|
||||
<DownloadIcon />
|
||||
{t('current_wallet.button_deposit')}
|
||||
|
|
@ -104,7 +104,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="light:text-black mt-8 mb-4 flex w-full flex-col gap-8 text-white">
|
||||
<div className="light:text-black mt-8 mb-4 flex w-full flex-col gap-8 text-white" data-tour-id="wallet-jars">
|
||||
<div className="text-muted-foreground hover:text-foreground">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import { useState, type ComponentProps } from 'react'
|
||||
import { AlertTriangleIcon, BlocksIcon, BookOpenIcon, FileQuestionMarkIcon, ScrollTextIcon } from 'lucide-react'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BlocksIcon,
|
||||
BookOpenIcon,
|
||||
FileQuestionMarkIcon,
|
||||
ScrollTextIcon,
|
||||
SparklesIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import { useStore } from 'zustand'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog'
|
||||
import { JmWebsocketInfo } from '@/components/ui/jam/JmWebsocketInfo'
|
||||
import { POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
|
||||
import type { JmWebsocket } from '@/hooks/useJmWebsocket'
|
||||
import type { SemanticVersion } from '@/lib/utils'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
|
|
@ -83,7 +91,16 @@ export function AppFooter({
|
|||
</Button>
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-start gap-2 sm:justify-center">
|
||||
<div className="flex flex-1 items-center justify-start gap-2 sm:justify-center" data-tour-id="footer-tools">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent(POST_LOGIN_TOUR_EVENT))}
|
||||
title="Tour"
|
||||
>
|
||||
<SparklesIcon />
|
||||
<span className="hidden sm:inline-block">Tour</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClickCheatsheet} title={t('footer.cheatsheet')}>
|
||||
<FileQuestionMarkIcon />
|
||||
<span className="hidden sm:inline-block">{t('footer.cheatsheet')}</span>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const WalletPreview = ({
|
|||
|
||||
return (
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link to={routes.home} className="flex items-center gap-2">
|
||||
<Link to={routes.home} className="flex items-center gap-2" data-tour-id="wallet-preview">
|
||||
<div className="flex h-8 w-8 items-center">
|
||||
{isLoading || isReloading ? (
|
||||
<Spinner className="text-muted-foreground size-6 motion-reduce:hidden" strokeWidth={3} />
|
||||
|
|
@ -216,6 +216,7 @@ export function AppNavbar({
|
|||
)}
|
||||
<ThemeToggleButton className="hidden sm:flex" variant="ghost-navbar" theme={theme} onClick={toggleTheme} />
|
||||
<Button
|
||||
data-tour-id="settings-button"
|
||||
variant="ghost-navbar"
|
||||
size="icon"
|
||||
onClick={() => void navigate(routes.settings)}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import { useState, useEffect } from 'react'
|
|||
import type { TFunction } from 'i18next'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, type NavigateFunction } from 'react-router-dom'
|
||||
import { useLocation, useNavigate, type NavigateFunction } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { AppFooter } from '@/components/layout/AppFooter'
|
||||
import { AppNavbar } from '@/components/layout/AppNavbar'
|
||||
import { Sidebar, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
||||
import { useSidebar } from '@/components/ui/use-sidebar'
|
||||
import { APP_DISPLAY_VERSION, JAM_DEFAULT_THEME } from '@/constants/jam'
|
||||
import { routes } from '@/constants/routes'
|
||||
import { useRescanStatus } from '@/context/JamSessionInfoContext'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { useCheatsheet } from '@/hooks/useCheatsheet'
|
||||
|
|
@ -21,6 +22,7 @@ import { LogsOverlay } from '../LogsOverlay'
|
|||
import { OrderbookOverlay } from '../orderbook/OrderbookOverlay'
|
||||
import { Cheatsheet } from '../ui/jam/Cheatsheet'
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { PostLoginOnboardingTour } from './PostLoginOnboardingTour'
|
||||
|
||||
const SIDEBAR_SIDE: React.ComponentProps<typeof Sidebar>['side'] = 'right'
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ type LayoutInnerProps = {
|
|||
export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const jmSession = useStore(jmSessionStore, (state) => state.state)
|
||||
const rescanStatus = useRescanStatus()
|
||||
|
||||
|
|
@ -53,6 +56,8 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
|
|||
const [isOrderbookOverlayOpen, setIsOrderbookOverlayOpen] = useState(false)
|
||||
const [isLogsOverlayOpen, setIsLogsOverlayOpen] = useState(false)
|
||||
const { isFeatureEnabled } = useFeatures()
|
||||
const isHomeRoute = location.pathname === routes.home
|
||||
const hasBlockingOverlayOpen = cheatsheet.open || isOrderbookOverlayOpen || isLogsOverlayOpen
|
||||
|
||||
// Adds a keyboard shortcut to toggle the logs overlay.
|
||||
useEffect(() => {
|
||||
|
|
@ -96,6 +101,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
|
|||
<Cheatsheet open={cheatsheet.open} onOpenChange={cheatsheet.onOpenChange} />
|
||||
<OrderbookOverlay open={isOrderbookOverlayOpen} onOpenChange={setIsOrderbookOverlayOpen} />
|
||||
<LogsOverlay open={isLogsOverlayOpen} onOpenChange={setIsLogsOverlayOpen} />
|
||||
<PostLoginOnboardingTour enabled={isHomeRoute && !hasBlockingOverlayOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
225
src/components/layout/PostLoginOnboardingTour.tsx
Normal file
225
src/components/layout/PostLoginOnboardingTour.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY, POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type TourStep = {
|
||||
selector: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
selector: '[data-tour-id="wallet-preview"]',
|
||||
title: 'Wallet Snapshot',
|
||||
description:
|
||||
'This area shows your current wallet and total balance. Use it to quickly verify you are in the right wallet.',
|
||||
},
|
||||
{
|
||||
selector: '[data-tour-id="wallet-actions"]',
|
||||
title: 'Primary Actions',
|
||||
description: 'Start here for day-to-day usage: Receive for deposits and Send for withdrawals or coinjoin flows.',
|
||||
},
|
||||
{
|
||||
selector: '[data-tour-id="wallet-jars"]',
|
||||
title: 'Jars Overview',
|
||||
description: 'Jars help you separate funds by mixdepth. Click any jar to inspect UTXOs and details.',
|
||||
},
|
||||
{
|
||||
selector: '[data-tour-id="footer-tools"]',
|
||||
title: 'Quick Tools',
|
||||
description: 'Open Cheatsheet, Orderbook, and Logs from here without leaving the current page.',
|
||||
},
|
||||
{
|
||||
selector: '[data-tour-id="settings-button"]',
|
||||
title: 'Settings & Safety',
|
||||
description: 'Use Settings to manage lock wallet, language, display mode, and other important preferences.',
|
||||
},
|
||||
]
|
||||
|
||||
const getTargetRect = (selector: string): DOMRect | null => {
|
||||
const target = document.querySelector(selector)
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
|
||||
const styles = window.getComputedStyle(target)
|
||||
if (styles.display === 'none' || styles.visibility === 'hidden') return null
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
if (rect.width < 8 || rect.height < 8) return null
|
||||
|
||||
return rect
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
||||
|
||||
interface PostLoginOnboardingTourProps {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const PostLoginOnboardingTour = ({ enabled = true }: PostLoginOnboardingTourProps) => {
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY) !== '1'
|
||||
} catch (error) {
|
||||
console.warn('Failed to read post-login onboarding preference:', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
||||
|
||||
const currentStep = TOUR_STEPS[stepIndex]
|
||||
const isLastStep = stepIndex === TOUR_STEPS.length - 1
|
||||
|
||||
const closeTour = () => {
|
||||
setOpen(false)
|
||||
setStepIndex(0)
|
||||
try {
|
||||
window.localStorage.setItem(POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY, '1')
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist post-login onboarding preference:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onStartTour = () => {
|
||||
setStepIndex(0)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener(POST_LOGIN_TOUR_EVENT, onStartTour)
|
||||
return () => window.removeEventListener(POST_LOGIN_TOUR_EVENT, onStartTour)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const updateTargetRect = () => {
|
||||
setTargetRect(getTargetRect(currentStep.selector))
|
||||
}
|
||||
|
||||
const targetElement = document.querySelector(currentStep.selector)
|
||||
if (targetElement instanceof HTMLElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
|
||||
updateTargetRect()
|
||||
|
||||
window.addEventListener('resize', updateTargetRect)
|
||||
window.addEventListener('scroll', updateTargetRect, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateTargetRect)
|
||||
window.removeEventListener('scroll', updateTargetRect, true)
|
||||
}
|
||||
}, [currentStep.selector, open])
|
||||
|
||||
const overlayStyles = useMemo(() => {
|
||||
if (!targetRect) return null
|
||||
|
||||
const padding = 8
|
||||
return {
|
||||
left: targetRect.left - padding,
|
||||
top: targetRect.top - padding,
|
||||
width: targetRect.width + padding * 2,
|
||||
height: targetRect.height + padding * 2,
|
||||
}
|
||||
}, [targetRect])
|
||||
|
||||
const tooltipStyles = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { left: 16, top: 16, width: 320 }
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const cardWidth = Math.min(380, viewportWidth - 32)
|
||||
const estimatedCardHeight = 220
|
||||
const margin = 16
|
||||
|
||||
if (!targetRect) {
|
||||
return {
|
||||
left: (viewportWidth - cardWidth) / 2,
|
||||
top: Math.max(margin, (viewportHeight - estimatedCardHeight) / 2),
|
||||
width: cardWidth,
|
||||
}
|
||||
}
|
||||
|
||||
const left = clamp(
|
||||
targetRect.left + targetRect.width / 2 - cardWidth / 2,
|
||||
margin,
|
||||
viewportWidth - cardWidth - margin,
|
||||
)
|
||||
|
||||
const belowTop = targetRect.bottom + 12
|
||||
const aboveTop = targetRect.top - estimatedCardHeight - 12
|
||||
|
||||
const top =
|
||||
belowTop + estimatedCardHeight <= viewportHeight - margin
|
||||
? belowTop
|
||||
: clamp(aboveTop, margin, viewportHeight - estimatedCardHeight - margin)
|
||||
|
||||
return { left, top, width: cardWidth }
|
||||
}, [targetRect])
|
||||
|
||||
if (!enabled || !open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
|
||||
{overlayStyles && (
|
||||
<div
|
||||
className="absolute rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)] transition-all duration-200"
|
||||
style={overlayStyles}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card className={cn('absolute max-w-[calc(100vw-2rem)] shadow-2xl')} style={tooltipStyles}>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">{currentStep.title}</CardTitle>
|
||||
<CardDescription>
|
||||
Step {stepIndex + 1} of {TOUR_STEPS.length}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed">{currentStep.description}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={closeTour}>
|
||||
Skip tour
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStepIndex((current) => Math.max(0, current - 1))}
|
||||
disabled={stepIndex === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isLastStep) {
|
||||
closeTour()
|
||||
return
|
||||
}
|
||||
setStepIndex((current) => Math.min(TOUR_STEPS.length - 1, current + 1))
|
||||
}}
|
||||
>
|
||||
{isLastStep ? 'Finish' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
import { useState, 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'
|
||||
|
|
@ -12,6 +12,7 @@ import { Spinner } from '@/components/ui/spinner'
|
|||
import { routes } from '@/constants/routes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LoginForm } from './LoginForm'
|
||||
import { OnboardingDialog } from './OnboardingDialog'
|
||||
|
||||
type LoginFormProps = ComponentProps<typeof LoginForm>
|
||||
type LoginCardProps = Omit<LoginFormProps, 'loading' | 'onSubmit'> &
|
||||
|
|
@ -23,6 +24,21 @@ type LoginCardProps = Omit<LoginFormProps, 'loading' | 'onSubmit'> &
|
|||
onReloadClick: () => Promise<void>
|
||||
}
|
||||
|
||||
const ONBOARDING_DISMISSED_STORAGE_KEY = 'jam:v2:onboarding:dismissed'
|
||||
|
||||
const getInitialOnboardingDialogState = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(ONBOARDING_DISMISSED_STORAGE_KEY) !== '1'
|
||||
} catch (error) {
|
||||
console.warn('Failed to access onboarding preference:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const LoginCard = ({
|
||||
wallets,
|
||||
activeWallet,
|
||||
|
|
@ -37,91 +53,110 @@ export const LoginCard = ({
|
|||
}: LoginCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [showOnboarding, setShowOnboarding] = useState(getInitialOnboardingDialogState)
|
||||
|
||||
const onOnboardingOpenChange = (open: boolean) => {
|
||||
setShowOnboarding(open)
|
||||
if (!open) {
|
||||
try {
|
||||
window.localStorage.setItem(ONBOARDING_DISMISSED_STORAGE_KEY, '1')
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist onboarding preference:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> </div>
|
||||
<div> </div>
|
||||
</div>
|
||||
</>
|
||||
<>
|
||||
<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" />
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
<WalletIcon className="text-primary" onClick={() => void onReloadClick()} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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}
|
||||
<Button variant="link" size="sm" className="h-auto px-0" onClick={() => setShowOnboarding(true)}>
|
||||
{t('onboarding.splashscreen_button_get_started')}
|
||||
</Button>
|
||||
</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> </div>
|
||||
<div> </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>
|
||||
|
||||
<OnboardingDialog open={showOnboarding} onOpenChange={onOnboardingOpenChange} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
182
src/components/login/OnboardingDialog.tsx
Normal file
182
src/components/login/OnboardingDialog.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { useState } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { HandshakeIcon, KeyRoundIcon, ShieldCheckIcon, UsersIcon, WalletIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type OnboardingStep = {
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
titleKey: 'onboarding.screen_1_title',
|
||||
descriptionKey: 'onboarding.screen_1_description',
|
||||
icon: WalletIcon,
|
||||
},
|
||||
{
|
||||
titleKey: 'onboarding.screen_2_title',
|
||||
descriptionKey: 'onboarding.screen_2_description',
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
titleKey: 'onboarding.screen_3_title',
|
||||
descriptionKey: 'onboarding.screen_3_description',
|
||||
icon: KeyRoundIcon,
|
||||
},
|
||||
{
|
||||
titleKey: 'onboarding.screen_4_title',
|
||||
descriptionKey: 'onboarding.screen_4_description',
|
||||
icon: HandshakeIcon,
|
||||
},
|
||||
{
|
||||
titleKey: 'onboarding.screen_5_title',
|
||||
descriptionKey: 'onboarding.screen_5_description',
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
]
|
||||
|
||||
interface OnboardingDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const OnboardingDialog = ({ open, onOpenChange }: OnboardingDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
const isSplashStep = step === 0
|
||||
const isLastStep = step === ONBOARDING_STEPS.length
|
||||
|
||||
const closeDialog = () => {
|
||||
setStep(0)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
setStep(0)
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
if (isSplashStep) {
|
||||
setStep(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (isLastStep) {
|
||||
closeDialog()
|
||||
return
|
||||
}
|
||||
|
||||
setStep((currentStep) => Math.min(currentStep + 1, ONBOARDING_STEPS.length))
|
||||
}
|
||||
|
||||
const onBack = () => setStep((currentStep) => Math.max(0, currentStep - 1))
|
||||
|
||||
const activeStep = ONBOARDING_STEPS[Math.max(0, step - 1)]
|
||||
const ActiveIcon = activeStep?.icon ?? WalletIcon
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
{isSplashStep ? (
|
||||
<>
|
||||
<DialogHeader className="space-y-3 text-left">
|
||||
<DialogTitle className="text-2xl">{t('onboarding.splashscreen_title')}</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">{t('onboarding.splashscreen_subtitle')}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/40 rounded-lg border p-4">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{t('onboarding.splashscreen_description_line1')}
|
||||
<br />
|
||||
{t('onboarding.splashscreen_description_line2')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<Badge variant="destructive">{t('onboarding.splashscreen_warning_title')}</Badge>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
<Trans i18nKey="onboarding.splashscreen_warning_text">
|
||||
While JoinMarket is tried and tested, Jam is not. It is beta software, so please{' '}
|
||||
<a
|
||||
href="https://github.com/joinmarket-webui/jam/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
help to improve the project on GitHub
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://jamdocs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
read the documentation
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button variant="ghost" onClick={closeDialog}>
|
||||
{t('onboarding.splashscreen_button_skip_intro')}
|
||||
</Button>
|
||||
<Button onClick={onNext}>{t('onboarding.splashscreen_button_get_started')}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader className="space-y-4 pr-10 text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ONBOARDING_STEPS.map((_, index) => (
|
||||
<span
|
||||
key={`progress-dot-${index}`}
|
||||
className={cn(
|
||||
'h-1.5 w-full rounded-full transition-colors',
|
||||
index === step - 1 ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="bg-primary/10 text-primary flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<ActiveIcon className="size-7" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">{activeStep?.titleKey ? t(activeStep.titleKey) : undefined}</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{activeStep?.descriptionKey ? t(activeStep.descriptionKey) : undefined}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
{t('global.back')}
|
||||
</Button>
|
||||
<Button onClick={onNext}>
|
||||
{isLastStep ? t('onboarding.button_complete') : t('onboarding.button_next')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
2
src/constants/onboarding.ts
Normal file
2
src/constants/onboarding.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const POST_LOGIN_TOUR_EVENT = 'jam:start-post-login-tour'
|
||||
export const POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY = 'jam:v2:post-login-tour:dismissed'
|
||||
Loading…
Add table
Add a link
Reference in a new issue