Skip to content
Merged
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
39 changes: 17 additions & 22 deletions frontend/src/app/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
import React, { Suspense } from 'react';
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import Home from '../page';

expect.extend(toHaveNoViolations);
jest.mock('../../components/LandingPage', () => ({
LandingPage: () => <div data-testid="landing-page-stub" />,
}));

describe('Home page loading fallback', () => {
it('LoadingSpinner fallback has role=status', () => {
render(<LoadingSpinner aria-label="Loading page" />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
describe('Home page dynamic loading fallback', () => {
it('renders exactly one loading indicator with a consistent aria-label while the dynamic import resolves', async () => {
render(<Home />);

it('LoadingSpinner fallback has aria-live=polite', () => {
render(<LoadingSpinner aria-label="Loading page" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite');
});
const indicators = screen.getAllByRole('status');
expect(indicators).toHaveLength(1);
expect(indicators[0]).toHaveAttribute('aria-label', 'Loading page');
expect(indicators[0]).toHaveAttribute('aria-live', 'polite');

it('LoadingSpinner fallback has an accessible label', () => {
render(<LoadingSpinner aria-label="Loading page" />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading page');
});
await waitFor(() => {
expect(screen.getByTestId('landing-page-stub')).toBeInTheDocument();
});

it('LoadingSpinner fallback has no axe violations', async () => {
const { container } = render(<LoadingSpinner aria-label="Loading page" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
expect(screen.queryAllByRole('status')).toHaveLength(0);
});
});
12 changes: 4 additions & 8 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import { LoadingSpinner } from '../components/LoadingSpinner';

// Dynamic import with code splitting
// Dynamic import with code splitting. With ssr: true, next/dynamic manages
// its own loading boundary, so no outer <Suspense> is needed here.
const LandingPage = dynamic(() => import('../components/LandingPage').then(mod => ({ default: mod.LandingPage })), {
loading: () => <LoadingSpinner aria-label="Loading" />,
loading: () => <LoadingSpinner aria-label="Loading page" />,
ssr: true,
});

export default function Home() {
return (
<Suspense fallback={<LoadingSpinner aria-label="Loading page" />}>
<LandingPage />
</Suspense>
);
return <LandingPage />;
}
116 changes: 38 additions & 78 deletions frontend/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { api } from '../lib/api/client';
import { Statistics } from './Statistics';
import { ErrorBoundary } from './ErrorBoundary';
import { LoadingSpinner } from './LoadingSpinner';
import { FeatureCard } from './landing/FeatureCard';
import { Step } from './landing/Step';
import { FooterColumn } from './landing/FooterColumn';

interface LandingPageProps {
className?: string;
Expand Down Expand Up @@ -61,6 +64,32 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
}
};

const features = [
{ icon: '/icons/decentralized.svg', title: t('features.decentralized.title'), description: t('features.decentralized.description') },
{ icon: '/icons/secure.svg', title: t('features.secure.title'), description: t('features.secure.description') },
{ icon: '/icons/fast.svg', title: t('features.fast.title'), description: t('features.fast.description') },
];

const steps = [
{ title: t('howItWorks.step1.title'), description: t('howItWorks.step1.description') },
{ title: t('howItWorks.step2.title'), description: t('howItWorks.step2.description') },
{ title: t('howItWorks.step3.title'), description: t('howItWorks.step3.description') },
{ title: t('howItWorks.step4.title'), description: t('howItWorks.step4.description') },
];

const footerColumns = [
{ heading: t('footer.title'), headingLevel: 'h2' as const, tagline: t('footer.tagline') },
{ heading: t('footer.linksHeading'), links: [
{ href: '/docs', label: t('footer.documentation') },
{ href: '/github', label: t('footer.github') },
{ href: '/discord', label: t('footer.discord') },
] },
{ heading: t('footer.legalHeading'), links: [
{ href: '/privacy', label: t('footer.privacy') },
{ href: '/terms', label: t('footer.terms') },
] },
];

return (
<div className={className}>
{/* Skip to main content link */}
Expand Down Expand Up @@ -237,47 +266,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
<h2 id="features-heading">{t('features.heading')}</h2>

<div className="features-grid">
<article className="feature-card">
<img
src="/icons/decentralized.svg"
alt=""
aria-hidden="true"
width="64"
height="64"
/>
<h3>{t('features.decentralized.title')}</h3>
<p>
{t('features.decentralized.description')}
</p>
</article>

<article className="feature-card">
<img
src="/icons/secure.svg"
alt=""
aria-hidden="true"
width="64"
height="64"
/>
<h3>{t('features.secure.title')}</h3>
<p>
{t('features.secure.description')}
</p>
</article>

<article className="feature-card">
<img
src="/icons/fast.svg"
alt=""
aria-hidden="true"
width="64"
height="64"
/>
<h3>{t('features.fast.title')}</h3>
<p>
{t('features.fast.description')}
</p>
</article>
{features.map((feature) => (
<FeatureCard key={feature.title} {...feature} />
))}
</div>
</section>

Expand All @@ -286,22 +277,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
<h2 id="how-it-works-heading">{t('howItWorks.heading')}</h2>

<ol className="steps-list">
<li>
<h3>{t('howItWorks.step1.title')}</h3>
<p>{t('howItWorks.step1.description')}</p>
</li>
<li>
<h3>{t('howItWorks.step2.title')}</h3>
<p>{t('howItWorks.step2.description')}</p>
</li>
<li>
<h3>{t('howItWorks.step3.title')}</h3>
<p>{t('howItWorks.step3.description')}</p>
</li>
<li>
<h3>{t('howItWorks.step4.title')}</h3>
<p>{t('howItWorks.step4.description')}</p>
</li>
{steps.map((step) => (
<Step key={step.title} {...step} />
))}
</ol>
</section>

Expand All @@ -320,27 +298,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
{/* Footer */}
<footer role="contentinfo" id="contact">
<div className="footer-content">
<div className="footer-section">
<h2>{t('footer.title')}</h2>
<p>{t('footer.tagline')}</p>
</div>

<div className="footer-section">
<h3>{t('footer.linksHeading')}</h3>
<ul>
<li><a href="/docs">{t('footer.documentation')}</a></li>
<li><a href="/github">{t('footer.github')}</a></li>
<li><a href="/discord">{t('footer.discord')}</a></li>
</ul>
</div>

<div className="footer-section">
<h3>{t('footer.legalHeading')}</h3>
<ul>
<li><a href="/privacy">{t('footer.privacy')}</a></li>
<li><a href="/terms">{t('footer.terms')}</a></li>
</ul>
</div>
{footerColumns.map((column) => (
<FooterColumn key={column.heading} {...column} />
))}
</div>

<div className="footer-bottom">
Expand Down
13 changes: 0 additions & 13 deletions frontend/src/components/LoadingSpinner.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,4 @@
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

/* Visually hidden text for screen readers */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
13 changes: 0 additions & 13 deletions frontend/src/components/Skeleton.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,4 @@
100% {
background-position: 200% 0;
}
}

/* Visually hidden text for screen readers */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
45 changes: 45 additions & 0 deletions frontend/src/components/__tests__/LandingPage.dataDriven.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import LandingPage from '../LandingPage';
import { api } from '../../lib/api/client';

describe('LandingPage data-driven sections', () => {
beforeEach(() => {
jest
.spyOn(api, 'getStatistics')
.mockResolvedValue({ totalMarkets: 128, totalVolume: 45000, activeUsers: 512 });
});

afterEach(() => {
jest.restoreAllMocks();
});

it('renders one feature-card article per feature entry with translated copy', () => {
render(<LandingPage />);
const cards = document.querySelectorAll('.feature-card');
expect(cards).toHaveLength(3);
expect(screen.getByRole('heading', { name: 'Fully Decentralized' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Secure & Audited' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Lightning Fast' })).toBeInTheDocument();
});

it('renders the how-it-works steps as an ordered list with one item per step', () => {
render(<LandingPage />);
const steps = document.querySelectorAll('.steps-list > li');
expect(steps).toHaveLength(4);
expect(screen.getByRole('heading', { name: 'Create a Market' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Claim Winnings' })).toBeInTheDocument();
});

it('renders footer columns, including link lists, from the data array', () => {
render(<LandingPage />);
const columns = document.querySelectorAll('.footer-section');
expect(columns).toHaveLength(3);

expect(screen.getByRole('link', { name: 'Documentation' })).toHaveAttribute('href', '/docs');
expect(screen.getByRole('link', { name: 'GitHub' })).toHaveAttribute('href', '/github');
expect(screen.getByRole('link', { name: 'Discord' })).toHaveAttribute('href', '/discord');
expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute('href', '/privacy');
expect(screen.getByRole('link', { name: 'Terms of Service' })).toHaveAttribute('href', '/terms');
});
});
15 changes: 15 additions & 0 deletions frontend/src/components/landing/FeatureCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';

export interface FeatureCardProps {
icon: string;
title: string;
description: string;
}

export const FeatureCard: React.FC<FeatureCardProps> = ({ icon, title, description }) => (
<article className="feature-card">
<img src={icon} alt="" aria-hidden="true" width="64" height="64" />
<h3>{title}</h3>
<p>{description}</p>
</article>
);
37 changes: 37 additions & 0 deletions frontend/src/components/landing/FooterColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';

export interface FooterColumnLink {
href: string;
label: string;
}

export interface FooterColumnProps {
heading: string;
headingLevel?: 'h2' | 'h3';
tagline?: string;
links?: FooterColumnLink[];
}

export const FooterColumn: React.FC<FooterColumnProps> = ({
heading,
headingLevel = 'h3',
tagline,
links,
}) => {
const Heading = headingLevel;
return (
<div className="footer-section">
<Heading>{heading}</Heading>
{tagline && <p>{tagline}</p>}
{links && (
<ul>
{links.map((link) => (
<li key={link.href}>
<a href={link.href}>{link.label}</a>
</li>
))}
</ul>
)}
</div>
);
};
13 changes: 13 additions & 0 deletions frontend/src/components/landing/Step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

export interface StepProps {
title: string;
description: string;
}

export const Step: React.FC<StepProps> = ({ title, description }) => (
<li>
<h3>{title}</h3>
<p>{description}</p>
</li>
);
10 changes: 10 additions & 0 deletions frontend/src/lib/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ describe('i18n', () => {
const result = i18n.t('features.decentralized.title');
expect(result).toBe('Fully Decentralized');
});

it('should return default value when a key resolves to a nested object rather than a string', () => {
const result = i18n.t('features', 'default');
expect(result).toBe('default');
});

it('should return the key when a key resolves to a nested object and no default is provided', () => {
const result = i18n.t('features');
expect(result).toBe('features');
});
});

describe('loadLocaleFromStorage', () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ class I18n {

t(key: string, defaultValue?: string): string {
const keys = key.split('.');
let value: any = translations[this.currentLocale];
let value: Translations | string = translations[this.currentLocale];

for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
Expand Down
Loading
Loading