Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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'] },
},
],
});
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,13 +11,23 @@ export function App() {
<div className="flex min-h-screen flex-col">
<Header />
<AutoSign />
<OnboardingTour />
<main className="mx-auto w-full max-w-[720px] flex-1 px-4 pb-16 pt-8 sm:px-6 sm:pb-24 sm:pt-10">
<Routes>
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
<footer className="mx-auto flex w-full max-w-[720px] justify-end px-4 pb-6 sm:px-6">
<button
type="button"
onClick={restartOnboardingTour}
className="font-heading text-[10px] uppercase tracking-widest text-outline transition-colors hover:text-primary"
>
{copy.onboarding.restart}
</button>
</footer>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);

return (
<header className="border-b border-outline-variant bg-surface">
<header data-tour="header" className="border-b border-outline-variant bg-surface">
<div className="mx-auto flex max-w-[720px] items-center justify-between px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center gap-4">
<Link to="/send" className="flex items-center gap-2">
Expand Down
148 changes: 148 additions & 0 deletions src/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
@@ -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<string, number>) {
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<HTMLDivElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(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<HTMLElement>('button')?.focus(), 0);
}, [active, index]);

useEffect(() => {
if (!active) return;
const target = document.querySelector<HTMLElement>(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 (
<>
<div className="pointer-events-none fixed inset-0 z-40 bg-black/45" aria-hidden="true" />
<div
ref={dialogRef}
role="dialog"
aria-modal="false"
aria-labelledby="wraith-tour-title"
aria-describedby="wraith-tour-body"
className="fixed bottom-4 left-4 right-4 z-50 border border-outline-variant bg-surface-container p-4 shadow-2xl sm:bottom-6 sm:left-auto sm:right-6 sm:w-[360px] sm:p-5"
>
<div className="flex flex-col gap-3">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
{interpolate(copy.onboarding.stepCount, {
current: index + 1,
total: steps.length,
})}
</span>
<div className="flex flex-col gap-2">
<h2
id="wraith-tour-title"
className="font-heading text-sm font-bold uppercase tracking-widest text-on-surface"
>
{currentStep.title}
</h2>
<p
id="wraith-tour-body"
className="font-body text-sm leading-relaxed text-on-surface-variant"
>
{currentStep.body}
</p>
</div>
<div className="flex items-center justify-between gap-3 pt-2">
<button
type="button"
onClick={finish}
className="font-heading text-[10px] uppercase tracking-widest text-outline transition-colors hover:text-on-surface"
>
{copy.onboarding.skip}
</button>
<button
type="button"
onClick={next}
className="h-9 border border-primary px-4 font-heading text-[10px] uppercase tracking-widest text-primary transition-colors hover:bg-surface-bright"
>
{isLastStep ? copy.onboarding.done : copy.onboarding.next}
</button>
</div>
</div>
</div>
</>
);
}
7 changes: 4 additions & 3 deletions src/components/StellarSend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export function StellarSend() {

return (
<section className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<div data-tour="send-page" className="flex flex-col gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Stellar Testnet / XLM
</span>
Expand All @@ -209,7 +209,7 @@ export function StellarSend() {

{!stealthResult && (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
<div data-tour="recipient" className="flex flex-col gap-1.5">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Recipient Meta-Address
</label>
Expand All @@ -230,7 +230,7 @@ export function StellarSend() {
</div>
</div>

<div className="flex flex-col gap-1.5">
<div data-tour="amount" className="flex flex-col gap-1.5">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Amount
</label>
Expand Down Expand Up @@ -266,6 +266,7 @@ export function StellarSend() {
{error && <p className="text-sm text-error">{error}</p>}

<button
data-tour="submit"
onClick={handleSend}
disabled={!recipient || !amount || isPending}
className="h-12 w-full bg-primary font-heading text-[13px] font-semibold uppercase tracking-widest text-surface transition-colors hover:brightness-110 disabled:opacity-30"
Expand Down
12 changes: 8 additions & 4 deletions src/components/WalletConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ function CkbButton() {
export function WalletConnect() {
const { chain } = useChain();

if (chain === 'stellar') return <FreighterButton />;
if (chain === 'solana') return <SolanaButton />;
if (chain === 'ckb') return <CkbButton />;
return <HorizenButton />;
return (
<div data-tour="wallet">
{chain === 'stellar' && <FreighterButton />}
{chain === 'solana' && <SolanaButton />}
{chain === 'ckb' && <CkbButton />}
{chain === 'horizen' && <HorizenButton />}
</div>
);
}
Loading