Skip to content
Merged
6 changes: 6 additions & 0 deletions public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://usewraith.xyz/use-cases/calculator</loc>
<lastmod>2026-08-25</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://usewraith.xyz/roadmap</loc>
<lastmod>2026-08-25</lastmod>
Expand Down
1 change: 1 addition & 0 deletions scripts/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const knownRoutes = [
'/faq',
'/privacy',
'/use-cases',
'/use-cases/calculator',
'/roadmap',
'/case-studies',
'/stellar',
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const Faq = lazy(() => import('./pages/Faq'));
const Privacy = lazy(() => import('./pages/Privacy'));
const Newsletter = lazy(() => import('./pages/Newsletter'));
const UseCases = lazy(() => import('./pages/UseCases'));
const CostCalculatorPage = lazy(() => import('./pages/CostCalculatorPage'));
const Stellar = lazy(() => import('./pages/Stellar'));
const Roadmap = lazy(() => import('./pages/Roadmap'));
const Grants = lazy(() => import('./pages/Grants'));
Expand Down Expand Up @@ -81,6 +82,7 @@ export default function App() {
<Route path="/privacy" element={<Privacy />} />
<Route path="/newsletter" element={<Newsletter />} />
<Route path="/use-cases" element={<UseCases />} />
<Route path="/use-cases/calculator" element={<CostCalculatorPage />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/case-studies" element={<CaseStudies />} />
<Route path="/case-studies/:slug" element={<CaseStudies />} />
Expand Down
199 changes: 199 additions & 0 deletions src/__tests__/CostCalculator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import i18n from '../i18n';
import CostCalculator, {
DEFAULT_AVERAGE_PAYMENT,
DEFAULT_PAYMENTS,
MAX_AVERAGE_PAYMENT,
MAX_PAYMENTS,
calculateCost,
scenarioFromSearchParams,
scenarioToSearchParams,
type CostChain,
} from '../components/CostCalculator';

const chains: CostChain[] = [
{
id: 'alpha',
name: 'Alpha',
networkFeeUsd: 0.01,
sourceLabel: 'Test source',
sourceUrl: 'https://example.com/alpha-fee',
},
{
id: 'beta',
name: 'Beta',
networkFeeUsd: 0.02,
sourceLabel: 'Test source',
sourceUrl: 'https://example.com/beta-fee',
},
];

beforeEach(async () => {
await i18n.changeLanguage('en');
});

function LocationProbe() {
const location = useLocation();
return <output data-testid="location-search">{location.search}</output>;
}

function renderCalculator(initialEntry = '/use-cases/calculator') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<CostCalculator chains={chains} />
<LocationProbe />
</MemoryRouter>,
);
}

describe('CostCalculator', () => {
it('renders the default scenario and calculations', () => {
renderCalculator();

expect(screen.getByLabelText('Chain')).toHaveValue('alpha');
expect(screen.getByLabelText('Payments per month')).toHaveValue(DEFAULT_PAYMENTS);
expect(screen.getByLabelText('Average payment value (USD)')).toHaveValue(
DEFAULT_AVERAGE_PAYMENT,
);
expect(screen.getByText('$10.00')).toBeInTheDocument();
expect(screen.getByText('$1.00')).toBeInTheDocument();
expect(screen.getByText('$11.00')).toBeInTheDocument();
});

it('reconstructs a shared scenario from URL query params', () => {
renderCalculator('/use-cases/calculator?chain=beta&payments=250&avg=80');

expect(screen.getByLabelText('Chain')).toHaveValue('beta');
expect(screen.getByLabelText('Payments per month')).toHaveValue(250);
expect(screen.getByLabelText('Average payment value (USD)')).toHaveValue(80);
expect(screen.getByText('$5.00')).toBeInTheDocument();
expect(screen.getByText('$0.25')).toBeInTheDocument();
expect(screen.getByText('$5.25')).toBeInTheDocument();
});

it('writes scenario changes back to the URL', () => {
renderCalculator();

fireEvent.change(screen.getByLabelText('Chain'), { target: { value: 'beta' } });
fireEvent.change(screen.getByLabelText('Payments per month'), { target: { value: '500' } });
fireEvent.change(screen.getByLabelText('Average payment value (USD)'), {
target: { value: '40' },
});

const search = screen.getByTestId('location-search').textContent ?? '';
expect(search).toContain('chain=beta');
expect(search).toContain('payments=500');
expect(search).toContain('avg=40');
});

it('shows inline source notes and the overhead assumption', () => {
renderCalculator();

expect(screen.getByText(/Test source/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'fee source' })).toHaveAttribute(
'href',
'https://example.com/alpha-fee',
);
expect(screen.getByText(/documented calculator assumption/)).toBeInTheDocument();
});

it('rejects malformed and out-of-range shared state safely', () => {
const invalid = scenarioFromSearchParams(
new URLSearchParams('chain=missing&payments=-5&avg=not-a-number'),
chains,
);
expect(invalid).toEqual({
chainId: 'alpha',
paymentsPerMonth: DEFAULT_PAYMENTS,
averagePaymentUsd: DEFAULT_AVERAGE_PAYMENT,
});

const capped = scenarioFromSearchParams(
new URLSearchParams('chain=alpha&payments=999999999&avg=9999999999'),
chains,
);
expect(capped.paymentsPerMonth).toBe(MAX_PAYMENTS);
expect(capped.averagePaymentUsd).toBe(MAX_AVERAGE_PAYMENT);
});

it('does not commit fractional payment counts', () => {
renderCalculator('/use-cases/calculator?chain=alpha&payments=10&avg=25');

const payments = screen.getByLabelText('Payments per month');
fireEvent.change(payments, { target: { value: '1.5' } });

expect(screen.getByTestId('location-search')).toHaveTextContent('payments=10');
fireEvent.blur(payments);
expect(payments).toHaveValue(10);
});

it('resynchronizes an invalid draft when another scenario field changes', () => {
renderCalculator('/use-cases/calculator?chain=alpha&payments=10&avg=25');

const payments = screen.getByLabelText('Payments per month');
fireEvent.change(payments, { target: { value: '' } });
fireEvent.change(screen.getByLabelText('Chain'), { target: { value: 'beta' } });

expect(payments).toHaveValue(10);
expect(screen.getByTestId('location-search')).toHaveTextContent('chain=beta');
expect(screen.getByTestId('location-search')).toHaveTextContent('payments=10');
});

it('resets to canonical defaults', () => {
renderCalculator('/use-cases/calculator?chain=beta&payments=250&avg=80');

fireEvent.click(screen.getByRole('button', { name: 'Reset' }));

expect(screen.getByLabelText('Chain')).toHaveValue('alpha');
expect(screen.getByLabelText('Payments per month')).toHaveValue(1000);
expect(screen.getByLabelText('Average payment value (USD)')).toHaveValue(100);
expect(screen.getByTestId('location-search')).toHaveTextContent(
'?chain=alpha&payments=1000&avg=100',
);
});

it('copies a canonical standalone scenario URL and surfaces fallback failure', async () => {
const writeText = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(false),
});

renderCalculator('/use-cases/calculator?chain=beta&payments=250&avg=80');
const copy = screen.getByRole('button', { name: 'Copy scenario link' });

fireEvent.click(copy);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('/use-cases/calculator?chain=beta&payments=250&avg=80'),
);
expect(await screen.findByText('Scenario link copied.')).toBeInTheDocument();

fireEvent.click(copy);
expect(
await screen.findByText(
'Could not copy automatically. The URL still contains this scenario.',
),
).toBeInTheDocument();
});

it('serializes deterministically and calculates floating-point metrics safely', () => {
const params = scenarioToSearchParams({
chainId: 'beta',
paymentsPerMonth: 250,
averagePaymentUsd: 80,
});
expect(params.toString()).toBe('chain=beta&payments=250&avg=80');

const result = calculateCost(0.01, 1000, 100);
expect(result.monthlyTotal).toBe(11);
expect(result.annualTotal).toBe(132);
expect(result.effectiveCostPerPayment).toBeCloseTo(0.011, 10);
expect(result.costShare).toBeCloseTo(0.011, 10);
});
});
1 change: 1 addition & 0 deletions src/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const pages = [
{ path: '/roadmap', name: 'roadmap' },
{ path: '/privacy', name: 'privacy policy' },
{ path: '/use-cases', name: 'use cases' },
{ path: '/use-cases/calculator', name: 'payment cost calculator' },
{ path: '/stellar', name: 'Stellar page' },
{ path: '/case-studies', name: 'case studies list' },
{ path: '/case-studies/payroll-processor', name: 'case study detail' },
Expand Down
Loading
Loading