[v2] improve user onboarding UX on login (#1081)

This commit is contained in:
Parth 2026-02-16 15:20:53 +05:30 committed by theborakompanioni
parent 8642ec54c4
commit 58bb4583f0
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
8 changed files with 557 additions and 89 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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)}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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>&nbsp;</div>
<div>&nbsp;</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>&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>
<OnboardingDialog open={showOnboarding} onOpenChange={onOnboardingOpenChange} />
</>
)
}

View 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>
)
}

View 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'