From ae99aaee445b10b4f257ca1c794591ca6e434fa5 Mon Sep 17 00:00:00 2001 From: Kaycke Date: Fri, 29 May 2026 19:44:18 -0300 Subject: [PATCH] feat: add Stellar onboarding tour --- package.json | 2 + playwright.config.ts | 23 +++++ pnpm-lock.yaml | 38 ++++++++ src/App.tsx | 12 +++ src/components/Header.tsx | 2 +- src/components/OnboardingTour.tsx | 148 ++++++++++++++++++++++++++++++ src/components/StellarSend.tsx | 7 +- src/components/WalletConnect.tsx | 12 ++- src/i18n/en.json | 36 ++++++++ tests/e2e/onboarding-tour.spec.ts | 41 +++++++++ tsconfig.json | 1 + 11 files changed, 314 insertions(+), 8 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/components/OnboardingTour.tsx create mode 100644 src/i18n/en.json create mode 100644 tests/e2e/onboarding-tour.spec.ts diff --git a/package.json b/package.json index 886cfb3..c441b63 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "test:e2e": "playwright test", "preview": "vite preview", "format": "prettier --write .", "format:check": "prettier --check .", @@ -37,6 +38,7 @@ "devDependencies": { "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@playwright/test": "^1.60.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ee9c676 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b5c05..bc0bde1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@commitlint/config-conventional': specifier: ^19.0.0 version: 19.8.1 + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -732,6 +735,11 @@ packages: resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'The package is now available as "qr": npm install qr' + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@rainbow-me/rainbowkit@2.2.10': resolution: {integrity: sha512-8+E4die1A2ovN9t3lWxWnwqTGEdFqThXDQRj+E4eDKuUKyymYD+66Gzm6S9yfg8E95c6hmGlavGUfYPtl1EagA==} engines: {node: '>=12.4'} @@ -2593,6 +2601,11 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3387,6 +3400,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -5362,6 +5385,10 @@ snapshots: '@paulmillr/qr@0.2.1': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@rainbow-me/rainbowkit@2.2.10(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(viem@2.47.17(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6))(wagmi@2.19.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.1(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6)))(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(bufferutil@4.1.0)(react-native@0.85.1(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6))(react@19.2.5)(typescript@5.9.3)(utf-8-validate@6.0.6)(viem@2.47.17(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6))(zod@4.3.6))': dependencies: '@tanstack/react-query': 5.99.0(react@19.2.5) @@ -8112,6 +8139,9 @@ snapshots: fresh@0.5.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9012,6 +9042,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} pony-cause@2.1.11: {} diff --git a/src/App.tsx b/src/App.tsx index 41f52e0..e707b09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; +import { OnboardingTour, restartOnboardingTour } from '@/components/OnboardingTour'; +import copy from '@/i18n/en.json'; import Send from '@/pages/Send'; import Receive from '@/pages/Receive'; @@ -9,6 +11,7 @@ export function App() {
+
} /> @@ -16,6 +19,15 @@ export function App() { } />
+
+ +
); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4ea4696..5488f31 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -13,7 +13,7 @@ export function Header() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); return ( -
+
diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..21b4e13 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import copy from '@/i18n/en.json'; +import { useChain } from '@/context/ChainContext'; + +const STORAGE_KEY = 'wraith.tourCompleted'; +const EVENT_NAME = 'wraith:restart-tour'; + +type TourStep = (typeof copy.onboarding.steps)[number]; + +function interpolate(template: string, values: Record) { + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(values[key] ?? '')); +} + +function isReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +export function restartOnboardingTour() { + localStorage.removeItem(STORAGE_KEY); + window.dispatchEvent(new Event(EVENT_NAME)); +} + +export function OnboardingTour() { + const location = useLocation(); + const navigate = useNavigate(); + const { chain, setChain } = useChain(); + const [active, setActive] = useState(false); + const [index, setIndex] = useState(0); + const dialogRef = useRef(null); + const previousFocusRef = useRef(null); + + const steps = copy.onboarding.steps; + const currentStep: TourStep = steps[index]; + const isLastStep = index === steps.length - 1; + const targetSelector = `[data-tour="${currentStep.target}"]`; + + const start = useCallback(() => { + previousFocusRef.current = document.activeElement as HTMLElement | null; + if (location.pathname !== '/send') navigate('/send'); + if (chain !== 'stellar') setChain('stellar'); + setIndex(0); + setActive(true); + }, [chain, location.pathname, navigate, setChain]); + + const finish = useCallback(() => { + localStorage.setItem(STORAGE_KEY, 'true'); + setActive(false); + previousFocusRef.current?.focus(); + }, []); + + useEffect(() => { + if (localStorage.getItem(STORAGE_KEY)) return; + if (location.pathname === '/send' || location.pathname === '/') start(); + }, [location.pathname, start]); + + useEffect(() => { + const restart = () => start(); + window.addEventListener(EVENT_NAME, restart); + return () => window.removeEventListener(EVENT_NAME, restart); + }, [start]); + + useEffect(() => { + if (!active) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') finish(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [active, finish]); + + useEffect(() => { + if (!active) return; + window.setTimeout(() => dialogRef.current?.querySelector('button')?.focus(), 0); + }, [active, index]); + + useEffect(() => { + if (!active) return; + const target = document.querySelector(targetSelector); + target?.scrollIntoView({ + block: 'center', + behavior: isReducedMotion() ? 'auto' : 'smooth', + }); + }, [active, targetSelector]); + + const next = () => { + if (isLastStep) { + finish(); + return; + } + setIndex((nextIndex) => Math.min(nextIndex + 1, steps.length - 1)); + }; + + if (!active) return null; + + return ( + <> +