From 58bb4583f02bdb9fc1484cd29a8dd7d42e7be446 Mon Sep 17 00:00:00 2001 From: Parth <143504541+parrth20@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:20:53 +0530 Subject: [PATCH] [v2] improve user onboarding UX on login (#1081) --- src/components/MainWalletPage.tsx | 4 +- src/components/layout/AppFooter.tsx | 21 +- src/components/layout/AppNavbar.tsx | 3 +- src/components/layout/Layout.tsx | 8 +- .../layout/PostLoginOnboardingTour.tsx | 225 ++++++++++++++++++ src/components/login/LoginCard.tsx | 201 +++++++++------- src/components/login/OnboardingDialog.tsx | 182 ++++++++++++++ src/constants/onboarding.ts | 2 + 8 files changed, 557 insertions(+), 89 deletions(-) create mode 100644 src/components/layout/PostLoginOnboardingTour.tsx create mode 100644 src/components/login/OnboardingDialog.tsx create mode 100644 src/constants/onboarding.ts diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index 14470a3b..52f3290b 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -78,7 +78,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) )} -
+
-
+
+
) } diff --git a/src/components/layout/PostLoginOnboardingTour.tsx b/src/components/layout/PostLoginOnboardingTour.tsx new file mode 100644 index 00000000..110fb901 --- /dev/null +++ b/src/components/layout/PostLoginOnboardingTour.tsx @@ -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(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 ( +
+
+ + {overlayStyles && ( +
+ )} + + + + {currentStep.title} + + Step {stepIndex + 1} of {TOUR_STEPS.length} + + + +

{currentStep.description}

+
+ + +
+ + +
+
+
+
+ ) +} diff --git a/src/components/login/LoginCard.tsx b/src/components/login/LoginCard.tsx index d6f5cec2..a22bbc93 100644 --- a/src/components/login/LoginCard.tsx +++ b/src/components/login/LoginCard.tsx @@ -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 type LoginCardProps = Omit & @@ -23,6 +24,21 @@ type LoginCardProps = Omit & onReloadClick: () => Promise } +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 ( - - -
- {listWalletsFetching ? ( - - ) : ( - void onReloadClick()} /> - )} -
- {/*TODO: i18n */}Welcome to Jam - {listWalletsLoading ? ( - <> - - - ) : wallets && wallets.length > 0 ? ( - {/*TODO: i18n */}Select a wallet and enter your password to continue. - ) : undefined} -
- - - {listWalletsError ? ( - <> - - - {t('wallets.error_loading_failed')} - {listWalletsError.message || t('global.errors.reason_unknown')} - - - - ) : ( - <> - {wallets === undefined || listWalletsLoading ? ( - <> - -
-
 
-
 
-
- + <> + + +
+ {listWalletsFetching ? ( + ) : ( - <> - {wallets && wallets.length === 0 ? ( -
-

{t('wallets.subtitle_no_wallets')}

-
- ) : ( - - )} - -
- - -
- + void onReloadClick()} /> )} - - )} - - +
+ {/*TODO: i18n */}Welcome to Jam + {listWalletsLoading ? ( + <> + + + ) : wallets && wallets.length > 0 ? ( + {/*TODO: i18n */}Select a wallet and enter your password to continue. + ) : undefined} + +
+ + + {listWalletsError ? ( + <> + + + {t('wallets.error_loading_failed')} + {listWalletsError.message || t('global.errors.reason_unknown')} + + + + ) : ( + <> + {wallets === undefined || listWalletsLoading ? ( + <> + +
+
 
+
 
+
+ + ) : ( + <> + {wallets && wallets.length === 0 ? ( +
+

{t('wallets.subtitle_no_wallets')}

+
+ ) : ( + + )} + +
+ + +
+ + )} + + )} +
+
+ + + ) } diff --git a/src/components/login/OnboardingDialog.tsx b/src/components/login/OnboardingDialog.tsx new file mode 100644 index 00000000..f6b4c7aa --- /dev/null +++ b/src/components/login/OnboardingDialog.tsx @@ -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 ( + + + {isSplashStep ? ( + <> + + {t('onboarding.splashscreen_title')} +

{t('onboarding.splashscreen_subtitle')}

+
+ +
+
+

+ {t('onboarding.splashscreen_description_line1')} +
+ {t('onboarding.splashscreen_description_line2')} +

+
+ +
+ {t('onboarding.splashscreen_warning_title')} +

+ + While JoinMarket is tried and tested, Jam is not. It is beta software, so please{' '} + + help to improve the project on GitHub + {' '} + and{' '} + + read the documentation + + . + +

+
+
+ + + + + + + ) : ( + <> + +
+ {ONBOARDING_STEPS.map((_, index) => ( + + ))} +
+
+ +
+
+ +
+
+

{activeStep?.titleKey ? t(activeStep.titleKey) : undefined}

+

+ {activeStep?.descriptionKey ? t(activeStep.descriptionKey) : undefined} +

+
+
+ + + + + + + )} +
+
+ ) +} diff --git a/src/constants/onboarding.ts b/src/constants/onboarding.ts new file mode 100644 index 00000000..0cedb711 --- /dev/null +++ b/src/constants/onboarding.ts @@ -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'