diff --git a/frontend/README.md b/frontend/README.md
index ee47aff..edc20bc 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -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:
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 8eecf98..8e8fc1a 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -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 (
@@ -29,6 +30,7 @@ export default function HomePage() {
+
);
diff --git a/frontend/components/faq-accordion.test.tsx b/frontend/components/faq-accordion.test.tsx
new file mode 100644
index 0000000..bddfd39
--- /dev/null
+++ b/frontend/components/faq-accordion.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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');
+ });
+});
diff --git a/frontend/components/faq-accordion.tsx b/frontend/components/faq-accordion.tsx
new file mode 100644
index 0000000..25f9aec
--- /dev/null
+++ b/frontend/components/faq-accordion.tsx
@@ -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(0);
+ const buttonRefs = useRef>([]);
+
+ 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, 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 (
+
+
+
+
+
+
{faq.answer}
+
+
+ );
+ }),
+ [openIndex],
+ );
+
+ return (
+
+
+
+ Frequently asked questions
+
+
+ Answers for ephemeral accounts, wallets, claim safety, and what happens when a payment is unclaimed.
+