-
+
{isLoading || isReloading ? (
@@ -216,6 +216,7 @@ export function AppNavbar({
)}
)
}
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 (
+
+ )
+}
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'