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
11 changes: 11 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ The details step (`steps/details-step.tsx`) collects the payment details:

Validation runs on submit and then re-runs on every change so errors clear as soon as the input becomes valid. Errors are announced to assistive technology via `role="alert"` and linked to their fields with `aria-describedby`/`aria-invalid`.

## FAQ accordion

The homepage now includes an accessible FAQ accordion covering:

- What is an ephemeral account?
- Do recipients need a wallet?
- What happens if the payment is unclaimed?
- Is it safe?

The component uses keyboard navigation and WAI-ARIA accordion patterns so users can open and close items using Enter, Space, Arrow Up/Down, Home, and End.

## SDK wrapper (`lib/bridgelet.ts`)

`lib/bridgelet.ts` is the typed entry point for the bridgelet-sdk API. The confirm step of the send form creates ephemeral accounts through it:
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from 'next/link';
import { PageShell } from '@/components/page-shell';
import { HowItWorks } from '@/components/how-it-works';
import { CTABanner } from '@/components/cta-banner';
import { FAQAccordion } from '@/components/faq-accordion';

export default function HomePage() {
return (
Expand Down Expand Up @@ -29,6 +30,7 @@ export default function HomePage() {
</Link>
</nav>
</div>
<FAQAccordion />
</div>
</PageShell>
);
Expand Down
55 changes: 55 additions & 0 deletions frontend/components/faq-accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FAQAccordion } from './faq-accordion';

describe('FAQAccordion', () => {
it('renders the FAQ heading and questions', () => {
render(<FAQAccordion />);

expect(screen.getByRole('heading', { name: /frequently asked questions/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /what is an ephemeral account\?/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /do recipients need a wallet\?/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /what happens if the payment is unclaimed\?/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /is it safe\?/i })).toBeInTheDocument();
});

it('keeps all panels collapsed by default except the first one', () => {
render(<FAQAccordion />);

const firstButton = screen.getByRole('button', { name: /what is an ephemeral account\?/i });
expect(firstButton).toHaveAttribute('aria-expanded', 'true');

const secondButton = screen.getByRole('button', { name: /do recipients need a wallet\?/i });
expect(secondButton).toHaveAttribute('aria-expanded', 'false');
});

it('expands and collapses panels on click', async () => {
const user = userEvent.setup();
render(<FAQAccordion />);

const secondButton = screen.getByRole('button', { name: /do recipients need a wallet\?/i });
await user.click(secondButton);

expect(secondButton).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('region', { name: /do recipients need a wallet\?/i })).toBeVisible();

await user.click(secondButton);
expect(secondButton).toHaveAttribute('aria-expanded', 'false');
});

it('moves focus with arrow keys and toggles panel with enter', async () => {
const user = userEvent.setup();
render(<FAQAccordion />);

const firstButton = screen.getByRole('button', { name: /what is an ephemeral account\?/i });
const secondButton = screen.getByRole('button', { name: /do recipients need a wallet\?/i });

firstButton.focus();
await user.keyboard('{ArrowDown}');
expect(secondButton).toHaveFocus();

await user.keyboard('{Enter}');
expect(secondButton).toHaveAttribute('aria-expanded', 'true');
});
});
140 changes: 140 additions & 0 deletions frontend/components/faq-accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
'use client';

import { useState, useRef, useMemo, type KeyboardEvent } from 'react';

type FAQ = {
question: string;
answer: string;
};

const FAQS: FAQ[] = [
{
question: 'What is an ephemeral account?',
answer:
'An ephemeral account is a temporary Stellar account created for a single payment. It is funded by the sender, and the payment is swept to the recipient’s wallet only after the recipient claims it.',
},
{
question: 'Do recipients need a wallet?',
answer:
'Recipients do not need a wallet to receive the claim link or understand the payment. To complete the claim, they must submit a valid Stellar wallet address so the funds can be transferred into their own wallet.',
},
{
question: 'What happens if the payment is unclaimed?',
answer:
'If a payment is not claimed, the ephemeral account remains isolated and the funds stay there until the configured expiry or recovery process. This prevents the payment from being lost or mixed with other balances.',
},
{
question: 'Is it safe?',
answer:
'Yes. The ephemeral account is isolated for the payment, and the claim process requires a valid wallet address before funds are swept. No recipient private key is exposed by the payment creation flow.',
},
];

export function FAQAccordion() {
const [openIndex, setOpenIndex] = useState<number | null>(0);
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([]);

const buttonIdPrefix = 'faq-accordion-button-';
const panelIdPrefix = 'faq-accordion-panel-';

const handleToggle = (index: number) => {
setOpenIndex((current) => (current === index ? null : index));
};

const moveFocus = (index: number) => {
const button = buttonRefs.current[index];
if (button) {
button.focus();
}
};

const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const maxIndex = FAQS.length - 1;

if (event.key === 'ArrowDown') {
event.preventDefault();
moveFocus(index === maxIndex ? 0 : index + 1);
return;
}

if (event.key === 'ArrowUp') {
event.preventDefault();
moveFocus(index === 0 ? maxIndex : index - 1);
return;
}

if (event.key === 'Home') {
event.preventDefault();
moveFocus(0);
return;
}

if (event.key === 'End') {
event.preventDefault();
moveFocus(maxIndex);
return;
}

if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleToggle(index);
}
};

const faqList = useMemo(
() =>
FAQS.map((faq, index) => {
const isOpen = openIndex === index;
const buttonId = `${buttonIdPrefix}${index}`;
const panelId = `${panelIdPrefix}${index}`;

return (
<div key={faq.question} className="rounded-3xl border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-950">
<h3 className="m-0">
<button
ref={(node) => {
buttonRefs.current[index] = node;
}}
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => handleToggle(index)}
onKeyDown={(event) => handleKeyDown(event, index)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-base font-medium text-slate-950 transition hover:bg-slate-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:text-slate-100 dark:hover:bg-slate-900"
>
<span>{faq.question}</span>
<span className="shrink-0 rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold text-slate-700 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200">
{isOpen ? 'Open' : 'Closed'}
</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
hidden={!isOpen}
className="px-5 pb-5 pt-0 text-slate-700 dark:text-slate-300"
>
<p className="mt-2 text-sm leading-7">{faq.answer}</p>
</div>
</div>
);
}),
[openIndex],

Check warning on line 124 in frontend/components/faq-accordion.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, and build

React Hook useMemo has a missing dependency: 'handleKeyDown'. Either include it or remove the dependency array
);

return (
<section aria-labelledby="faq-heading" className="space-y-4 py-8">
<div className="space-y-2">
<h2 id="faq-heading" className="text-2xl font-semibold tracking-tight text-slate-950 dark:text-white">
Frequently asked questions
</h2>
<p className="max-w-2xl text-sm text-slate-600 dark:text-slate-400">
Answers for ephemeral accounts, wallets, claim safety, and what happens when a payment is unclaimed.
</p>
</div>
<div className="grid gap-3">{faqList}</div>
</section>
);
}
2 changes: 1 addition & 1 deletion frontend/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
63 changes: 0 additions & 63 deletions frontend/package-lock.json

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

8 changes: 8 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,13 @@
},
"engines": {
"node": ">=20.10.0"
},
"allowScripts": {
"esbuild@0.28.1": true,
"esbuild@0.21.5": true,
"esbuild@0.25.12": true,
"sharp@0.34.5": true,
"msw@2.15.0": true,
"unrs-resolver@1.12.2": true
}
}
Loading