From 180c37d570ea38778bb72b8ff127c01d78e35152 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:33:10 +0530 Subject: [PATCH 01/11] feat: add calculator fee data adapter --- src/data/calculatorChains.ts | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/data/calculatorChains.ts diff --git a/src/data/calculatorChains.ts b/src/data/calculatorChains.ts new file mode 100644 index 0000000..8eedd9b --- /dev/null +++ b/src/data/calculatorChains.ts @@ -0,0 +1,48 @@ +export type CalculatorChain = { + id: string; + name: string; + networkFeeUsd: number; + sourceLabel: string; + sourceUrl: string; +}; + +/** + * Temporary fee-data boundary for issue #128 while issue #127 owns the + * canonical `src/data/chains.json` schema. + * + * These values are planning baselines, not canonical Wraith fee data. The + * linked protocol documentation explains the underlying fee mechanics. Once + * #127 lands, replace this array with a narrow adapter over `chains.json` + * without changing calculator logic. + */ +export const CALCULATOR_CHAINS: readonly CalculatorChain[] = [ + { + id: 'stellar', + name: 'Stellar', + networkFeeUsd: 0.0001, + sourceLabel: 'temporary planning baseline pending #127', + sourceUrl: + 'https://developers.stellar.org/docs/build/guides/transactions/send-and-receive-payments', + }, + { + id: 'solana', + name: 'Solana', + networkFeeUsd: 0.001, + sourceLabel: 'temporary planning baseline pending #127', + sourceUrl: 'https://solana.com/docs/core/fees', + }, + { + id: 'nervos-ckb', + name: 'Nervos CKB', + networkFeeUsd: 0.0005, + sourceLabel: 'temporary planning baseline pending #127', + sourceUrl: 'https://docs.nervos.org/docs/tech-explanation/glossary', + }, + { + id: 'horizen', + name: 'Horizen', + networkFeeUsd: 0.005, + sourceLabel: 'temporary planning baseline; execution/data fees vary', + sourceUrl: 'https://docs.horizen.io/horizen-chain/tokens-and-gas/gas-on-horizen/', + }, +]; From 0d7a1e3c1a31a5068d8ad3d66f1e753a703b837a Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:33:53 +0530 Subject: [PATCH 02/11] feat: add stealth payment cost calculator --- src/components/CostCalculator.tsx | 456 ++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 src/components/CostCalculator.tsx diff --git a/src/components/CostCalculator.tsx b/src/components/CostCalculator.tsx new file mode 100644 index 0000000..f4cc89d --- /dev/null +++ b/src/components/CostCalculator.tsx @@ -0,0 +1,456 @@ +import { useEffect, useMemo, useState, type ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { + CALCULATOR_CHAINS, + type CalculatorChain, +} from '../data/calculatorChains'; +import { copyToClipboard } from '../utils/clipboard'; + +export type CostChain = CalculatorChain; + +/** + * Modelled client/gateway work for one stealth-payment scenario. + * This is deliberately isolated from network fees: it is a calculator + * assumption, not an on-chain Wraith protocol fee, and can be revised without + * changing the calculation or URL-state model. + */ +export const STEALTH_OVERHEAD_USD_PER_PAYMENT = 0.001; + +export const DEFAULT_PAYMENTS = 1000; +export const DEFAULT_AVERAGE_PAYMENT = 100; +export const MIN_PAYMENTS = 1; +export const MIN_AVERAGE_PAYMENT = 0.01; +export const MAX_PAYMENTS = 1_000_000; +export const MAX_AVERAGE_PAYMENT = 100_000_000; + +export type CostScenario = { + chainId: string; + paymentsPerMonth: number; + averagePaymentUsd: number; +}; + +function firstChain(chains: readonly CostChain[]) { + const chain = chains[0]; + if (!chain) throw new Error('CostCalculator requires at least one chain'); + return chain; +} + +function boundedNumber(value: string | null, fallback: number, min: number, max: number) { + if (value === null || value.trim() === '') return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < min) return fallback; + return Math.min(parsed, max); +} + +function boundedInteger(value: string | null, fallback: number, min: number, max: number) { + const parsed = boundedNumber(value, fallback, min, max); + return Number.isInteger(parsed) ? parsed : fallback; +} + +export function scenarioFromSearchParams( + params: URLSearchParams, + chains: readonly CostChain[], +): CostScenario { + const fallbackChain = firstChain(chains); + const requestedChain = params.get('chain'); + const selectedChain = chains.find((chain) => chain.id === requestedChain) ?? fallbackChain; + + return { + chainId: selectedChain.id, + paymentsPerMonth: boundedInteger( + params.get('payments'), + DEFAULT_PAYMENTS, + MIN_PAYMENTS, + MAX_PAYMENTS, + ), + averagePaymentUsd: boundedNumber( + params.get('avg'), + DEFAULT_AVERAGE_PAYMENT, + MIN_AVERAGE_PAYMENT, + MAX_AVERAGE_PAYMENT, + ), + }; +} + +export function scenarioToSearchParams(scenario: CostScenario) { + return new URLSearchParams({ + chain: scenario.chainId, + payments: String(scenario.paymentsPerMonth), + avg: String(scenario.averagePaymentUsd), + }); +} + +export function calculateCost( + networkFeeUsd: number, + paymentsPerMonth: number, + averagePaymentUsd: number, +) { + const networkFees = networkFeeUsd * paymentsPerMonth; + const stealthOverhead = STEALTH_OVERHEAD_USD_PER_PAYMENT * paymentsPerMonth; + const monthlyTotal = networkFees + stealthOverhead; + const monthlyVolume = paymentsPerMonth * averagePaymentUsd; + + return { + networkFees, + stealthOverhead, + monthlyTotal, + annualTotal: monthlyTotal * 12, + monthlyVolume, + effectiveCostPerPayment: paymentsPerMonth > 0 ? monthlyTotal / paymentsPerMonth : 0, + costShare: monthlyVolume > 0 ? (monthlyTotal / monthlyVolume) * 100 : 0, + }; +} + +function formatUsd(value: number, locale: string) { + return value.toLocaleString(locale, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: value > 0 && value < 0.01 ? 4 : 2, + maximumFractionDigits: value > 0 && value < 0.01 ? 6 : 2, + }); +} + +type Props = { + chains?: readonly CostChain[]; +}; + +export default function CostCalculator({ chains = CALCULATOR_CHAINS }: Props) { + const { t, i18n } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en'; + const fallbackChain = useMemo(() => firstChain(chains), [chains]); + const scenario = useMemo( + () => scenarioFromSearchParams(searchParams, chains), + [chains, searchParams], + ); + const selectedChain = chains.find((chain) => chain.id === scenario.chainId) ?? fallbackChain; + + const [paymentsInput, setPaymentsInput] = useState(String(scenario.paymentsPerMonth)); + const [averageInput, setAverageInput] = useState(String(scenario.averagePaymentUsd)); + const [shareStatus, setShareStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); + + useEffect(() => { + setPaymentsInput(String(scenario.paymentsPerMonth)); + setAverageInput(String(scenario.averagePaymentUsd)); + }, [scenario.chainId, scenario.paymentsPerMonth, scenario.averagePaymentUsd]); + + const totals = useMemo( + () => + calculateCost( + selectedChain.networkFeeUsd, + scenario.paymentsPerMonth, + scenario.averagePaymentUsd, + ), + [selectedChain.networkFeeUsd, scenario.paymentsPerMonth, scenario.averagePaymentUsd], + ); + + const updateScenario = (updates: Partial) => { + setSearchParams(scenarioToSearchParams({ ...scenario, ...updates }), { replace: true }); + setShareStatus('idle'); + }; + + const updateNumericScenario = ( + key: 'paymentsPerMonth' | 'averagePaymentUsd', + raw: string, + min: number, + max: number, + integerOnly = false, + ) => { + const parsed = Number(raw); + if ( + raw.trim() === '' || + !Number.isFinite(parsed) || + parsed < min || + parsed > max || + (integerOnly && !Number.isInteger(parsed)) + ) { + return; + } + updateScenario({ [key]: parsed }); + }; + + const resetScenario = () => { + updateScenario({ + chainId: fallbackChain.id, + paymentsPerMonth: DEFAULT_PAYMENTS, + averagePaymentUsd: DEFAULT_AVERAGE_PAYMENT, + }); + }; + + const copyScenarioLink = async () => { + const path = `/use-cases/calculator?${scenarioToSearchParams(scenario).toString()}`; + const url = typeof window === 'undefined' ? path : new URL(path, window.location.origin).href; + + try { + await copyToClipboard(url); + setShareStatus('copied'); + } catch { + setShareStatus('failed'); + } + }; + + return ( +
+
+
+ + {t('costCalculator.eyebrow', { defaultValue: 'Cost estimator' })} + +

+ {t('costCalculator.heading', { + defaultValue: 'Estimate the cost of private payments', + })} +

+

+ {t('costCalculator.description', { + defaultValue: + 'Model a monthly payment scenario, compare network fees with a documented stealth-generation overhead assumption, and share the exact scenario from the URL.', + })} +

+
+ +
+
+ + {t('costCalculator.scenario', { defaultValue: 'Scenario' })} + + +
+
+ + +
+ +
+ + ) => { + setPaymentsInput(event.target.value); + updateNumericScenario( + 'paymentsPerMonth', + event.target.value, + MIN_PAYMENTS, + MAX_PAYMENTS, + true, + ); + }} + onBlur={() => setPaymentsInput(String(scenario.paymentsPerMonth))} + className="h-11 w-full border border-outline bg-surface px-3 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + /> +

+ {t('costCalculator.paymentsHint', { + defaultValue: '1 to {{max}} payments.', + max: MAX_PAYMENTS.toLocaleString(locale), + })} +

+
+ +
+ + ) => { + setAverageInput(event.target.value); + updateNumericScenario( + 'averagePaymentUsd', + event.target.value, + MIN_AVERAGE_PAYMENT, + MAX_AVERAGE_PAYMENT, + ); + }} + onBlur={() => setAverageInput(String(scenario.averagePaymentUsd))} + className="h-11 w-full border border-outline bg-surface px-3 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" + /> +

+ {t('costCalculator.averageHint', { + defaultValue: + 'Used for payment-volume context; it does not change the network fee estimate.', + })} +

+
+ +
+ + + + {shareStatus === 'copied' + ? t('costCalculator.copySuccess', { + defaultValue: 'Scenario link copied.', + }) + : shareStatus === 'failed' + ? t('costCalculator.copyFailure', { + defaultValue: + 'Could not copy automatically. The URL still contains this scenario.', + }) + : ''} + +
+
+
+ +
+
+
+

+ {t('costCalculator.networkFees', { defaultValue: 'Estimated network fees' })} +

+

+ {formatUsd(totals.networkFees, locale)} +

+

+ {t('costCalculator.perPayment', { + defaultValue: '{{fee}} per payment', + fee: formatUsd(selectedChain.networkFeeUsd, locale), + })}{' '} + · {selectedChain.sourceLabel} ·{' '} + + {t('costCalculator.feeSource', { defaultValue: 'fee source' })} + +

+
+ +
+

+ {t('costCalculator.stealthOverhead', { + defaultValue: 'Modelled stealth generation overhead', + })} +

+

+ {formatUsd(totals.stealthOverhead, locale)} +

+

+ {t('costCalculator.overheadNote', { + defaultValue: + '{{fee}} per payment · documented calculator assumption, not an on-chain protocol fee', + fee: formatUsd(STEALTH_OVERHEAD_USD_PER_PAYMENT, locale), + })} +

+
+
+ +
+

+ {t('costCalculator.monthlyTotal', { defaultValue: 'Estimated monthly total' })} +

+

+ {formatUsd(totals.monthlyTotal, locale)} +

+

+ {t('costCalculator.monthlyContext', { + defaultValue: + 'Across {{payments}} payments and {{volume}} in monthly payment volume.', + payments: scenario.paymentsPerMonth.toLocaleString(locale), + volume: formatUsd(totals.monthlyVolume, locale), + })} +

+
+ +
+
+
+ {t('costCalculator.effectiveCost', { + defaultValue: 'Effective cost / payment', + })} +
+
+ {formatUsd(totals.effectiveCostPerPayment, locale)} +
+
+
+
+ {t('costCalculator.feeLoad', { + defaultValue: 'Fee load vs payment volume', + })} +
+
+ {totals.costShare.toFixed(4)}% +
+
+
+
+ {t('costCalculator.annualEstimate', { defaultValue: '12-month estimate' })} +
+
+ {formatUsd(totals.annualTotal, locale)} +
+
+
+ +

+ {t('costCalculator.disclaimer', { + defaultValue: + 'Estimates are directional. Network fees can change with chain conditions; source notes identify the planning constants used by this calculator. Canonical chain data will replace the temporary adapter when #127 lands.', + })} +

+
+
+
+
+ ); +} From 805958c6efc150e1a16b6ee84e7acdf45b60097e Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:34:06 +0530 Subject: [PATCH 03/11] feat: add standalone calculator page --- src/pages/CostCalculatorPage.tsx | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/pages/CostCalculatorPage.tsx diff --git a/src/pages/CostCalculatorPage.tsx b/src/pages/CostCalculatorPage.tsx new file mode 100644 index 0000000..0bbbab8 --- /dev/null +++ b/src/pages/CostCalculatorPage.tsx @@ -0,0 +1,55 @@ +import { Helmet } from 'react-helmet-async'; +import { Link } from 'react-router-dom'; +import CostCalculator from '../components/CostCalculator'; +import Footer from '../components/Footer'; + +export default function CostCalculatorPage() { + return ( +
+ + Payment Cost Calculator | Wraith Protocol + + + + + + Skip to content + + +
+
+ + + + WRAITH + + +
+
+ +
+
+
+

+ Use cases / calculator +

+

+ Payment Cost Calculator +

+

+ Estimate monthly costs for a private-payment scenario, then share the exact chain, + volume, and average payment assumptions from the URL. +

+
+
+ + +
+ +
+
+ ); +} From 14ef1e8bc88618e3b96dee5f746ca518f7a50896 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:35:29 +0530 Subject: [PATCH 04/11] test: cover cost calculator scenarios --- src/__tests__/CostCalculator.test.tsx | 199 ++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/__tests__/CostCalculator.test.tsx diff --git a/src/__tests__/CostCalculator.test.tsx b/src/__tests__/CostCalculator.test.tsx new file mode 100644 index 0000000..601d45c --- /dev/null +++ b/src/__tests__/CostCalculator.test.tsx @@ -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 {location.search}; +} + +function renderCalculator(initialEntry = '/use-cases/calculator') { + return render( + + + + , + ); +} + +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); + }); +}); From 516918bdbc79a9131ba1b735d57f73889275e5b7 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:35:55 +0530 Subject: [PATCH 05/11] feat: route standalone cost calculator --- src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 929b223..706d664 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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')); @@ -81,6 +82,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> From 2791b4a7cc36694c1a721ded3662fcfa8730b249 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:36:23 +0530 Subject: [PATCH 06/11] feat: embed calculator on use-cases --- src/pages/UseCases.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/UseCases.tsx b/src/pages/UseCases.tsx index 08fd625..e6ddcd5 100644 --- a/src/pages/UseCases.tsx +++ b/src/pages/UseCases.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom'; +import CostCalculator from '../components/CostCalculator'; import Footer from '../components/Footer'; type Persona = { @@ -194,6 +195,8 @@ export default function UseCases() { + + {/* CTA */}
From 8b95ec96eb6ead74f4c1afdbd48b9aa5b704d6bc Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:37:28 +0530 Subject: [PATCH 07/11] test: add calculator unit accessibility coverage --- src/__tests__/a11y.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/a11y.test.tsx b/src/__tests__/a11y.test.tsx index 234fba6..daf28f7 100644 --- a/src/__tests__/a11y.test.tsx +++ b/src/__tests__/a11y.test.tsx @@ -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' }, From ce4fcc2bda8eafc40557e34c28642bf395545102 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:37:45 +0530 Subject: [PATCH 08/11] test: add calculator keyboard and axe coverage --- tests/a11y/a11y.spec.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/a11y/a11y.spec.ts b/tests/a11y/a11y.spec.ts index 0e4f6e9..1768925 100644 --- a/tests/a11y/a11y.spec.ts +++ b/tests/a11y/a11y.spec.ts @@ -7,6 +7,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' }, @@ -46,7 +47,7 @@ test('keyboard navigation works on homepage', async ({ page }) => { }); test('skip link is present on pages that use it', async ({ page }) => { - const pagesWithSkipLink = ['/', '/use-cases', '/roadmap']; + const pagesWithSkipLink = ['/', '/use-cases', '/use-cases/calculator', '/roadmap']; for (const path of pagesWithSkipLink) { await page.goto(path); @@ -55,6 +56,26 @@ test('skip link is present on pages that use it', async ({ page }) => { } }); +test('cost calculator is fully keyboard operable', async ({ page }) => { + await page.goto('/use-cases/calculator?chain=stellar&payments=1000&avg=100'); + + const chain = page.getByLabel('Chain'); + await chain.focus(); + await expect(chain).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(page.getByLabel('Payments per month')).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(page.getByLabel('Average payment value (USD)')).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Copy scenario link' })).toBeFocused(); + + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Reset' })).toBeFocused(); +}); + test('reduced-motion preference is respected', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/'); From c83c44a1dc750569047bf7f0f5c0b93039b08a58 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:38:04 +0530 Subject: [PATCH 09/11] chore: add calculator route to sitemap --- scripts/sitemap.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/sitemap.ts b/scripts/sitemap.ts index 603ba70..e34da6b 100644 --- a/scripts/sitemap.ts +++ b/scripts/sitemap.ts @@ -14,6 +14,7 @@ const knownRoutes = [ '/faq', '/privacy', '/use-cases', + '/use-cases/calculator', '/roadmap', '/case-studies', '/stellar', From 0219e5f37ad1af53a3ed156ef7b4104e4e204ab7 Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:38:24 +0530 Subject: [PATCH 10/11] chore: include calculator in public sitemap --- public/sitemap.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/public/sitemap.xml b/public/sitemap.xml index d3d2b0a..4dd4a66 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -24,6 +24,12 @@ weekly 0.8 + + https://usewraith.xyz/use-cases/calculator + 2026-08-25 + weekly + 0.8 + https://usewraith.xyz/roadmap 2026-08-25 From c358aff78b2901ed50971b00278c1fc1be5a500d Mon Sep 17 00:00:00 2001 From: ranvirjrj-beep Date: Fri, 28 Aug 2026 08:52:23 +0530 Subject: [PATCH 11/11] chore: format cost calculator --- src/components/CostCalculator.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/CostCalculator.tsx b/src/components/CostCalculator.tsx index f4cc89d..597c111 100644 --- a/src/components/CostCalculator.tsx +++ b/src/components/CostCalculator.tsx @@ -1,10 +1,7 @@ import { useEffect, useMemo, useState, type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; -import { - CALCULATOR_CHAINS, - type CalculatorChain, -} from '../data/calculatorChains'; +import { CALCULATOR_CHAINS, type CalculatorChain } from '../data/calculatorChains'; import { copyToClipboard } from '../utils/clipboard'; export type CostChain = CalculatorChain;