diff --git a/README.md b/README.md index 2db3b46..2c1ef4a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ platform for verified NGOs on Stellar. - Stellar SDK (`@stellar/stellar-sdk`) for Soroban contract calls - Vitest and Testing Library for unit and component tests - Playwright for end-to-end tests +- next-intl for i18n (scaffolding so far — see [docs/I18N.md](./docs/I18N.md)) See [docs/COMPONENTS.md](./docs/COMPONENTS.md) for a component tree of `src/components/` with a one-line description of each piece, @@ -25,7 +26,8 @@ the dark-mode strategy. See [docs/DATA_FETCHING.md](./docs/DATA_FETCHING.md) for server/client data loading, cache revalidation, polling intervals, and the known 1+N impact -fetch pattern. +fetch pattern, and [docs/I18N.md](./docs/I18N.md) for how translations are +wired up and how to add a language. ## Local development diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 0000000..3e92470 --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,95 @@ +# Internationalization + +This is scaffolding, not a finished rollout: [next-intl](https://next-intl.dev) +is wired up, the landing page (`src/app/(main)/page.tsx` and +`src/components/landing/HowItWorks.tsx`) reads its copy from translation +files, and a locale switcher in the header lets you try it — but every other +page still has its English strings hard-coded. The goal here was to get the +plumbing in place once, so translating the rest of the app later means +extracting strings into `messages/*.json` page by page, not introducing the +library and its request-time wiring from scratch. + +## How it's wired up + +- **`src/i18n/config.ts`** — the list of supported locales (`LOCALES`), the + default (`DEFAULT_LOCALE`), and their display labels. +- **`src/i18n/request.ts`** — next-intl's per-request config. It reads the + `NEXT_LOCALE` cookie (falling back to `DEFAULT_LOCALE`) and loads that + locale's `messages/.json`. +- **`next.config.ts`** — wraps the config with `createNextIntlPlugin`, + pointing at `request.ts`. +- **`src/app/layout.tsx`** — resolves the locale and messages server-side + (`getLocale`/`getMessages`) and wraps the app in `NextIntlClientProvider`, + so both Server and Client Components can translate. +- **`messages/en.json` / `messages/es.json`** — the two locales that exist + today, namespaced by component (`HomePage`, `HowItWorks`, + `LocaleSwitcher`). +- **`src/components/layout/LocaleSwitcher.tsx`** — the switcher itself, in + the header. It writes the `NEXT_LOCALE` cookie and calls `router.refresh()` + — there's no `[locale]` route segment, so the URL doesn't change. + +## Why there's no locale-prefixed routing + +next-intl's full setup adds a `[locale]` segment above every route (so URLs +look like `/es/ngos/123`), plus its own routing middleware. This app already +has `src/middleware.ts` doing CSP/security headers and a route-group layout +(`src/app/(main)/`) added in a separate pass — layering locale-prefixed +routing on top of both would touch every route, every internal ``, and +the embed widget's URL contract (`docs/EMBED.md`), which NGOs have already +pasted into their own sites. That's a real migration, not scaffolding, so +it's deliberately left for whenever full i18n is actually prioritized. The +cookie-based approach here gets translations, a working switcher, and the +request-config plumbing in place without any of that. + +**Cost of that shortcut:** `request.ts` reads a cookie to resolve the +locale, and reading a cookie forces Next.js to render dynamically rather +than statically — since `NextIntlClientProvider` lives in the root layout, +this applies to every route, not just the translated ones. Before this +change, `next build` marked `/`, `/ngos`, and the other public routes as +static (`○`, with `/` and `/ngos` revalidating every 30s); after it, every +route is server-rendered per request (`ƒ`). Locale-prefixed routing (the +"real migration" above) would get static generation back, one prerendered +page per locale — that's the other reason it's the better long-term answer, +not just the URL structure. + +## Adding a language + +1. Add its code to `LOCALES` and a label to `LOCALE_LABELS` in + `src/i18n/config.ts`. +2. Copy `messages/en.json` to `messages/.json` and translate every + value (keep the keys and the `HowItWorks.steps` array shape identical — + `request.ts` loads whichever file matches the active locale by name, and + `HowItWorks` reads `steps` as a raw array via `t.raw('steps')`). +3. That's it — the switcher reads `LOCALES` directly, so the new language + appears there automatically. + +## Translating another component + +1. Add a namespace for it to every `messages/.json` file (match the + component's name, e.g. `"Footer": { "github": "GitHub" }`). +2. In a **Server Component**, call `const t = await getTranslations('Footer')` + from `next-intl/server` and use `t('github')` in place of the literal + string (see `src/app/(main)/page.tsx` or + `src/components/landing/HowItWorks.tsx`). +3. In a **Client Component** (one with `'use client'`), use + `const t = useTranslations('Footer')` from `next-intl` instead — same + `t('key')` call, no `await` (see `LocaleSwitcher.tsx`). +4. A list or other non-string value (like `HowItWorks.steps`) can't go + through `t()` directly — use `t.raw('key')` to get it back as plain JSON. + +## Testing a component that uses translations + +Rendering a component that calls `useLocale()`/`useTranslations()` outside +a `NextIntlClientProvider` throws. Either wrap it in one for the test, or — +the lighter option used in `Header.test.tsx` for `LocaleSwitcher` — mock +`next-intl` directly: + +```ts +vi.mock('next-intl', () => ({ + useLocale: () => 'en', + useTranslations: () => (key: string) => key, +})); +``` + +A Server Component using `getTranslations` (from `next-intl/server`) needs +the equivalent mock for that module instead. diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 0000000..a548b21 --- /dev/null +++ b/messages/en.json @@ -0,0 +1,27 @@ +{ + "HomePage": { + "heroHeading": "Give as a stream, not a one-time click.", + "heroSubtext": "StreamGive lets you support verified NGOs on Stellar with continuous, cancel-anytime donations — transparent, on-chain, and instant.", + "exploreNgos": "Explore NGOs" + }, + "HowItWorks": { + "heading": "How it works", + "steps": [ + { + "title": "Connect your wallet", + "description": "Freighter or any supported Stellar wallet — one click, no account to create." + }, + { + "title": "Start a stream", + "description": "Pick a verified NGO, set an amount and a rate. They get paid continuously, second by second — not in one lump sum." + }, + { + "title": "Stay in control", + "description": "Top up, adjust the rate, or cancel anytime. Unspent funds always come back to you, instantly." + } + ] + }, + "LocaleSwitcher": { + "label": "Language" + } +} diff --git a/messages/es.json b/messages/es.json new file mode 100644 index 0000000..c4f2a6c --- /dev/null +++ b/messages/es.json @@ -0,0 +1,27 @@ +{ + "HomePage": { + "heroHeading": "Dona como un flujo, no como un clic único.", + "heroSubtext": "StreamGive te permite apoyar a ONGs verificadas en Stellar con donaciones continuas y cancelables en cualquier momento: transparentes, en la cadena y al instante.", + "exploreNgos": "Explorar ONGs" + }, + "HowItWorks": { + "heading": "Cómo funciona", + "steps": [ + { + "title": "Conecta tu billetera", + "description": "Freighter o cualquier billetera Stellar compatible: un clic, sin necesidad de crear una cuenta." + }, + { + "title": "Inicia un flujo", + "description": "Elige una ONG verificada, define un monto y una tasa. Reciben el pago de forma continua, segundo a segundo, no en una sola suma." + }, + { + "title": "Mantén el control", + "description": "Recarga, ajusta la tasa o cancela en cualquier momento. Los fondos no utilizados siempre vuelven a ti, al instante." + } + ] + }, + "LocaleSwitcher": { + "label": "Idioma" + } +} diff --git a/next.config.ts b/next.config.ts index 1acc8ad..6abcec7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,7 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { // Don't advertise the framework/version in an X-Powered-By header. @@ -14,4 +17,4 @@ const nextConfig: NextConfig = { output: process.env.VERCEL ? undefined : 'standalone', }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index 6574588..0b06779 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@creit.tech/stellar-wallets-kit": "^2.6.0", "@stellar/stellar-sdk": "^17.0.1", "next": "^16.3.4", + "next-intl": "^4.3.1", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/src/app/(main)/apply/page.tsx b/src/app/(main)/apply/page.tsx index cd208ef..fa1a406 100644 --- a/src/app/(main)/apply/page.tsx +++ b/src/app/(main)/apply/page.tsx @@ -183,6 +183,7 @@ export default function ApplyPage() { Organization name setName(event.target.value)} required @@ -210,6 +211,8 @@ export default function ApplyPage() { Contact email setContactEmail(event.target.value)} required @@ -221,6 +224,8 @@ export default function ApplyPage() { Website (optional) setWebsite(event.target.value)} placeholder="https://" @@ -232,6 +237,7 @@ export default function ApplyPage() { Country (optional) setCountry(event.target.value)} maxLength={COUNTRY_MAX_LENGTH} diff --git a/src/app/(main)/dashboard/page.test.tsx b/src/app/(main)/dashboard/page.test.tsx index f5dd221..1ed8f22 100644 --- a/src/app/(main)/dashboard/page.test.tsx +++ b/src/app/(main)/dashboard/page.test.tsx @@ -60,6 +60,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); } @@ -85,6 +87,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); let resolveFetch: (streams: Stream[]) => void = () => {}; @@ -115,6 +119,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); render(); @@ -132,6 +138,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); const wallet = vi.spyOn(WalletProviderMod, 'useWallet'); wallet.mockReturnValue(walletFor('GOLD')); @@ -297,6 +305,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); vi.spyOn(ApiMod, 'getStreams').mockResolvedValue(streams); render(); @@ -389,6 +399,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); vi.spyOn(ApiMod, 'getStreams').mockResolvedValue(streams); vi.mocked(CsvMod.buildDonationHistoryCsv).mockReturnValue('id,status\n101,ACTIVE'); @@ -530,6 +542,8 @@ describe('DashboardPage', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); vi.spyOn(ApiMod, 'getStreams').mockResolvedValue([streamOldest, streamMiddle, streamNewest]); } diff --git a/src/app/(main)/ngos/[id]/page.tsx b/src/app/(main)/ngos/[id]/page.tsx index a675bb2..0b6f086 100644 --- a/src/app/(main)/ngos/[id]/page.tsx +++ b/src/app/(main)/ngos/[id]/page.tsx @@ -5,15 +5,11 @@ import { notFound } from 'next/navigation'; import { CopyLinkButton } from '@/components/common/CopyLinkButton'; import { DonateQrCode } from '@/components/ngos/DonateQrCode'; import { getNgo, getStreams, type NgoProfile, type Stream } from '@/lib/api'; -import { formatAmount, formatTokenAmount, truncateAddress } from '@/lib/format'; +import { formatAmount, formatDate, formatTokenAmount, truncateAddress } from '@/lib/format'; import { explorerUrl } from '@/lib/stellar'; const RECENT_STREAMS_LIMIT = 10; -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(); -} - /** Only http(s) URLs are rendered as links, so a stored `javascript:` or * other scheme can never become a clickable href. */ function safeWebsiteUrl(website: string): string | null { @@ -37,9 +33,33 @@ export async function generateMetadata({ params }: Props): Promise { return { title: 'NGO not found' }; } + const description = `Support ${ngo.name} with a recurring, streaming donation on Stellar.`; + // Relative — resolved against metadataBase (the root layout sets it to + // NEXT_PUBLIC_APP_URL), same as the root layout's own openGraph/twitter. + const profileUrl = `/ngos/${id}`; + return { title: ngo.name, - description: `Support ${ngo.name} with a recurring, streaming donation on Stellar.`, + description, + alternates: { + canonical: profileUrl, + }, + openGraph: { + title: ngo.name, + description, + type: 'website', + url: profileUrl, + // /opengraph-image is the site-wide share image (src/app/opengraph-image.tsx) + // — there's no per-NGO image to generate one from, so every profile + // shares it rather than going without a preview image at all. + images: [{ url: '/opengraph-image', width: 1200, height: 630, alt: 'StreamGive' }], + }, + twitter: { + card: 'summary_large_image', + title: ngo.name, + description, + images: ['/opengraph-image'], + }, }; } diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 4303447..7979b40 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -1,28 +1,30 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; +import { Suspense } from 'react'; import { HowItWorks } from '@/components/landing/HowItWorks'; import { StatsStrip } from '@/components/landing/StatsStrip'; +import { StatsStripSkeleton } from '@/components/landing/StatsStripSkeleton'; + +export default async function Home() { + const t = await getTranslations('HomePage'); -export default function Home() { return ( <>
-

- Give as a stream, not a one-time click. -

-

- StreamGive lets you support verified NGOs on Stellar with continuous, cancel-anytime - donations — transparent, on-chain, and instant. -

+

{t('heroHeading')}

+

{t('heroSubtext')}

- Explore NGOs + {t('exploreNgos')}
- + }> + +
diff --git a/src/app/(main)/privacy/page.tsx b/src/app/(main)/privacy/page.tsx new file mode 100644 index 0000000..2d37c76 --- /dev/null +++ b/src/app/(main)/privacy/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; + +// A placeholder, not a published policy — see the page body. Kept out of +// search results so it can't be mistaken for StreamGive's actual privacy +// policy once one exists. +export const metadata: Metadata = { + title: 'Privacy Policy', + robots: { index: false, follow: true }, +}; + +export default function PrivacyPage() { + return ( +
+

Privacy Policy

+
+

StreamGive hasn't published a privacy policy yet.

+

+ In the meantime, what we actually hold: the frontend keeps no server-side session — + wallet addresses, NGO applications and transaction records are handled by{' '} + + streamgive-backend + + , and donation/withdrawal activity is itself public on the Stellar network, independent + of this site. +

+

+ Questions about data handling?{' '} + + Open an issue on GitHub + {' '}(opens in a new tab) + + . +

+
+
+ ); +} diff --git a/src/app/(main)/terms/page.tsx b/src/app/(main)/terms/page.tsx new file mode 100644 index 0000000..253e0d4 --- /dev/null +++ b/src/app/(main)/terms/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from 'next'; + +// A placeholder, not a published policy — see the page body. Kept out of +// search results so it can't be mistaken for StreamGive's actual terms +// once they exist. +export const metadata: Metadata = { + title: 'Terms of Service', + robots: { index: false, follow: true }, +}; + +export default function TermsPage() { + return ( +
+

Terms of Service

+
+

StreamGive hasn't published formal terms of service yet.

+

+ StreamGive is open-source software (see the{' '} + + GitHub organization + {' '}(opens in a new tab) + + ) that lets donors stream funds to verified NGOs on the Stellar network. Every + donation, stream and withdrawal it facilitates is an on-chain transaction you sign + yourself — the site is a client for the Stellar network, not a custodian of your funds. +

+

+ Questions in the meantime?{' '} + + Open an issue on GitHub + {' '}(opens in a new tab) + + . +

+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 59378d7..f4a4502 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getMessages } from 'next-intl/server'; import { AppShell } from '@/components/common/AppShell'; import { THEME_INIT_SCRIPT } from '@/lib/theme'; @@ -53,18 +55,23 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + // Both come from src/i18n/request.ts (wired up via next.config.ts's + // withNextIntl), which resolves the locale from the NEXT_LOCALE cookie. + const locale = await getLocale(); + const messages = await getMessages(); + return ( // suppressHydrationWarning: THEME_INIT_SCRIPT adds a `dark`/`light` // class here before React hydrates, which never matches the // server-rendered markup (the server doesn't know the client's // preference) — that mismatch is expected and this is the documented // way to silence the resulting warning without disabling it app-wide. - + {/* Must run synchronously, before paint, to avoid a flash of the wrong theme — see THEME_INIT_SCRIPT's own comment. */} @@ -73,7 +80,9 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/src/components/common/ConnectWalletPrompt.test.tsx b/src/components/common/ConnectWalletPrompt.test.tsx index db45991..15e711e 100644 --- a/src/components/common/ConnectWalletPrompt.test.tsx +++ b/src/components/common/ConnectWalletPrompt.test.tsx @@ -21,6 +21,8 @@ describe('ConnectWalletPrompt', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); }); diff --git a/src/components/dashboard/StreamControls.test.tsx b/src/components/dashboard/StreamControls.test.tsx index 7e3b898..80adb4a 100644 --- a/src/components/dashboard/StreamControls.test.tsx +++ b/src/components/dashboard/StreamControls.test.tsx @@ -480,6 +480,55 @@ describe('StreamControls', () => { expect(showToast).toHaveBeenCalledWith('error', 'Network error'); }); }); + + describe('keyboard focus across mode switches', () => { + it('moves focus to the amount input on entering top-up, and back to the Top up button on Back', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Top up' })); + expect(screen.getByLabelText(/amount to add/i)).toHaveFocus(); + + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByRole('button', { name: 'Top up' })).toHaveFocus(); + }); + + it('moves focus to the duration select on entering modify, and back to the Modify rate button on Back', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Modify rate' })); + expect(screen.getByLabelText(/new duration/i)).toHaveFocus(); + + await user.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByRole('button', { name: 'Modify rate' })).toHaveFocus(); + }); + + it('moves focus to "Yes, cancel" on entering the cancel confirmation, and back to the Cancel button on Never mind', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('button', { name: 'Yes, cancel' })).toHaveFocus(); + + await user.click(screen.getByRole('button', { name: 'Never mind' })); + expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus(); + }); + + it('moves focus to the status region when submitting leaves the trigger disabled', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Top up' })); + await user.type(screen.getByLabelText(/amount to add/i), '5'); + await user.click(screen.getByRole('button', { name: 'Confirm' })); + + // The Top up button is disabled (pending) by the time focus would + // move to it, so it can't take focus — the live region does instead. + expect(await screen.findByRole('button', { name: 'Topping up…' })).toBeDisabled(); + expect(screen.getByRole('status')).toHaveFocus(); + }); + }); }); describe('computeModifyRate', () => { diff --git a/src/components/dashboard/StreamControls.tsx b/src/components/dashboard/StreamControls.tsx index abbe0b3..1aa22f8 100644 --- a/src/components/dashboard/StreamControls.tsx +++ b/src/components/dashboard/StreamControls.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useToast } from '@/components/toast/ToastProvider'; import { useWallet } from '@/components/wallet/WalletProvider'; @@ -8,6 +8,7 @@ import { getStreams, type Stream } from '@/lib/api'; import { describeContractError } from '@/lib/contractTypes'; import { useDonationVaultClient } from '@/lib/donationVaultClient'; import { describeAmountError, formatEstimatedFee, formatRate, parseAmount } from '@/lib/format'; +import { DURATIONS } from '@/lib/durations'; import { txToastAction } from '@/lib/stellar'; /** @@ -24,13 +25,6 @@ export function computeModifyRate(balance: string, durationSeconds: number): big return rate > 0n ? rate : null; } -const DURATIONS = [ - { label: '1 week', seconds: 7 * 24 * 60 * 60 }, - { label: '1 month', seconds: 30 * 24 * 60 * 60 }, - { label: '3 months', seconds: 90 * 24 * 60 * 60 }, - { label: '1 year', seconds: 365 * 24 * 60 * 60 }, -]; - // The list this sits in comes from the backend's indexer, which polls on // an interval — a confirmed on-chain action can lag a few seconds before // onChanged()'s refresh actually reflects it. Said explicitly in the @@ -70,6 +64,52 @@ export function StreamControls({ const [topUpAmount, setTopUpAmount] = useState(''); const [estimatedFee, setEstimatedFee] = useState(null); + // Each mode replaces the whole subtree below, so switching modes unmounts + // whatever had focus — without this, focus falls back to . Each + // ref points at the control that mode-switch should land focus on: the + // new mode's first control when entering one, or the idle trigger that + // opened it when backing out (idleContainerRef is the fallback for when + // that trigger is mid-action and disabled, e.g. right after submitting). + const idleContainerRef = useRef(null); + const topUpButtonRef = useRef(null); + const modifyButtonRef = useRef(null); + const cancelButtonRef = useRef(null); + const topUpAmountInputRef = useRef(null); + const modifyDurationSelectRef = useRef(null); + const confirmCancelButtonRef = useRef(null); + const previousModeRef = useRef(mode); + + useEffect(() => { + const previousMode = previousModeRef.current; + previousModeRef.current = mode; + if (mode === previousMode) return; + + if (mode === 'toppingUp') { + topUpAmountInputRef.current?.focus(); + } else if (mode === 'modifying') { + modifyDurationSelectRef.current?.focus(); + } else if (mode === 'confirmingCancel') { + confirmCancelButtonRef.current?.focus(); + } else { + const trigger = + previousMode === 'toppingUp' + ? topUpButtonRef.current + : previousMode === 'modifying' + ? modifyButtonRef.current + : previousMode === 'confirmingCancel' + ? cancelButtonRef.current + : null; + // A trigger that just submitted its action is disabled (pending !== + // null) by the time this runs — a disabled button can't take focus, + // so the live region announcing that progress is the next best thing. + if (trigger && !trigger.disabled) { + trigger.focus(); + } else { + idleContainerRef.current?.focus(); + } + } + }, [mode]); + const topUpAmountRaw = parseAmount(topUpAmount); const topUpAmountError = describeAmountError(topUpAmount); @@ -176,6 +216,7 @@ export function StreamControls({
diff --git a/src/components/wallet/ConnectWalletButton.test.tsx b/src/components/wallet/ConnectWalletButton.test.tsx index d2c72b4..2d7cce0 100644 --- a/src/components/wallet/ConnectWalletButton.test.tsx +++ b/src/components/wallet/ConnectWalletButton.test.tsx @@ -35,6 +35,8 @@ describe('ConnectWalletButton disconnect flow', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }; }); }); @@ -73,6 +75,8 @@ describe('ConnectWalletButton connecting state', () => { signTransaction: vi.fn(), signMessage: vi.fn(), networkMismatch: false, + noWalletOptions: null, + dismissNoWalletPrompt: vi.fn(), }); render(); diff --git a/src/components/wallet/ConnectWalletButton.tsx b/src/components/wallet/ConnectWalletButton.tsx index 94d7959..b6d64dc 100644 --- a/src/components/wallet/ConnectWalletButton.tsx +++ b/src/components/wallet/ConnectWalletButton.tsx @@ -1,13 +1,10 @@ 'use client'; +import { truncateAddress } from '@/lib/format'; import { NETWORK_NAME } from '@/lib/stellar'; import { useWallet } from './WalletProvider'; -function truncateAddress(address: string): string { - return `${address.slice(0, 4)}…${address.slice(-4)}`; -} - export function ConnectWalletButton() { const { address, diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..d94e02d --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,24 @@ +/** + * The set of locales this app can render. Adding a new one is a two-step + * change: add its code here, then add the matching `messages/.json` + * file — see docs/I18N.md for the full walkthrough. + */ +export const LOCALES = ['en', 'es'] as const; + +export type Locale = (typeof LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = 'en'; + +export const LOCALE_LABELS: Record = { + en: 'English', + es: 'Español', +}; + +export function isLocale(value: string | undefined): value is Locale { + return LOCALES.includes(value as Locale); +} + +/** Name of the cookie the locale switcher writes and `request.ts` reads — + * no path-based locale routing yet (see docs/I18N.md), so this is the only + * thing that remembers the visitor's choice across requests. */ +export const LOCALE_COOKIE = 'NEXT_LOCALE'; diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..49fc476 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,22 @@ +import { cookies } from 'next/headers'; +import { getRequestConfig } from 'next-intl/server'; + +import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from './config'; + +/** + * Resolves the active locale for every request and loads its messages. + * + * There's no `[locale]` route segment yet (see docs/I18N.md for why, and + * what adding real locale-prefixed routing would involve) — the locale + * comes entirely from the `NEXT_LOCALE` cookie the switcher writes, + * falling back to DEFAULT_LOCALE for a first-time visitor. + */ +export default getRequestConfig(async () => { + const cookieLocale = (await cookies()).get(LOCALE_COOKIE)?.value; + const locale = isLocale(cookieLocale) ? cookieLocale : DEFAULT_LOCALE; + + return { + locale, + messages: (await import(`../../messages/${locale}.json`)).default, + }; +}); diff --git a/src/lib/adminApi.ts b/src/lib/adminApi.ts index 4e36870..a227840 100644 --- a/src/lib/adminApi.ts +++ b/src/lib/adminApi.ts @@ -1,8 +1,7 @@ import type { WalletSignMessage } from '@/components/wallet/WalletProvider'; import type { NgoApplication } from './api'; - -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +import { API_URL } from './config'; /** The backend accepts a signature within MAX_CLOCK_SKEW_MS (5 minutes) of * its timestamp, so one we just made stays valid for that long. Reused up diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 5fa1176..bbba50f 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -39,9 +39,14 @@ describe('api network errors', () => { await expect(getStreams({ donor: 'GABC' })).rejects.toThrow('fetch failed'); }); - it('rejects once a stalled request hits the 10s timeout', async () => { - vi.useFakeTimers(); - try { + it( + 'rejects once a stalled request hits the 10s timeout', + async () => { + // AbortSignal.timeout() schedules its own internal timer outside of + // the global setTimeout/setInterval that vi.useFakeTimers() patches, + // so it keeps running on the real clock even under fake timers — + // this has to let the real 10s elapse rather than fast-forward it. + // // A realistic hung fetch: the promise it returns never settles on its // own — it only rejects if/when the signal passed to it aborts, same // as a real fetch() would once AbortSignal.timeout() fires. @@ -56,15 +61,10 @@ describe('api network errors', () => { }), ); - const pending = getNgos(); - - await vi.advanceTimersByTimeAsync(10_000); - - await expect(pending).rejects.toBeTruthy(); - } finally { - vi.useRealTimers(); - } - }); + await expect(getNgos()).rejects.toBeTruthy(); + }, + 15_000, + ); }); // --------------------------------------------------------------------------- diff --git a/src/lib/api.ts b/src/lib/api.ts index fc09d3b..38fa803 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +import { API_URL } from './config'; // A hung backend should fail loudly rather than leave the UI on a loading // spinner forever. AbortSignal.timeout() rejects the fetch with a @@ -6,6 +6,14 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; // fetch (generic catch → "couldn't reach the API" messaging). const API_TIMEOUT_MS = 10_000; +/** The signal every fetch call below sends: the request's own timeout, + * combined with a caller-supplied signal (e.g. a polling effect's cleanup) + * when there is one, so either can cancel the fetch. */ +function withTimeout(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(API_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + /** Thrown by API calls that need callers to branch on the HTTP status * (e.g. 409 conflict vs. other failures) rather than just knowing a * request failed. */ @@ -39,9 +47,7 @@ export type Ngo = { export async function getNgos(signal?: AbortSignal): Promise { const res = await fetch(`${API_URL}/ngos`, { next: { revalidate: 30 }, - signal: signal - ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) - : AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(signal), }); if (!res.ok) { throw new Error(`Failed to fetch NGOs: ${res.status}`); @@ -81,9 +87,7 @@ export type NgoProfile = Ngo & { export async function getNgo(id: string, signal?: AbortSignal): Promise { const res = await fetch(`${API_URL}/ngos/${encodeURIComponent(id)}`, { next: { revalidate: 30 }, - signal: signal - ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) - : AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(signal), }); if (res.status === 404) { return null; @@ -108,7 +112,7 @@ export async function getNgo(id: string, signal?: AbortSignal): Promise { const params = new URLSearchParams({ address }); const res = await fetch(`${API_URL}/ngos/lookup?${params.toString()}`, { - signal: AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(), }); if (res.status === 404) { return null; @@ -152,9 +156,7 @@ export async function getStreams( if (filter.limit) params.set('limit', String(filter.limit)); const res = await fetch(`${API_URL}/streams?${params.toString()}`, { - signal: signal - ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) - : AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(signal), }); if (!res.ok) { throw new Error(`Failed to fetch streams: ${res.status}`); @@ -187,9 +189,7 @@ export type Withdrawal = { */ export async function getWithdrawals(ngoId: string, signal?: AbortSignal): Promise { const res = await fetch(`${API_URL}/ngos/${encodeURIComponent(ngoId)}/withdrawals`, { - signal: signal - ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) - : AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(signal), }); if (!res.ok) { throw new Error(`Failed to fetch withdrawals for NGO ${ngoId}: ${res.status}`); @@ -235,7 +235,7 @@ export async function getApplicationStatus( ): Promise { const params = new URLSearchParams({ ownerAddress }); const res = await fetch(`${API_URL}/ngo-applications/status?${params.toString()}`, { - signal: AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(), }); if (res.status === 404) { return null; @@ -286,7 +286,7 @@ export async function submitNgoApplication(input: NgoApplicationInput): Promise< method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input), - signal: AbortSignal.timeout(API_TIMEOUT_MS), + signal: withTimeout(), }); if (res.status === 409) { diff --git a/src/lib/config.ts b/src/lib/config.ts index 4d84294..4924837 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,3 +1,6 @@ +/** Base URL of the `streamgive-backend` API every fetch call targets. */ +export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; + /** * Server-render fallback for the app's own origin. * diff --git a/src/lib/csv.ts b/src/lib/csv.ts index 1aeb86a..b627d1a 100644 --- a/src/lib/csv.ts +++ b/src/lib/csv.ts @@ -1,6 +1,5 @@ import type { Stream } from './api'; -import { formatRemainingDuration, formatTokenAmount } from './format'; -import { getNativeAssetAddress } from './stellar'; +import { formatRemainingDuration, formatTokenAmount, tokenLabel } from './format'; const UTF8_BOM = '\uFEFF'; @@ -16,12 +15,6 @@ const CSV_HEADERS = [ 'Created', ]; -// There's no symbol/name resolution for arbitrary tokens — only the native -// asset's contract address is derivable client-side. -function tokenLabel(tokenAddress: string): string { - return tokenAddress === getNativeAssetAddress() ? 'XLM' : tokenAddress; -} - function escapeCsvField(value: string): string { // A field starting with =, +, - or @ runs as a formula when opened in a // spreadsheet — prefix it with a single quote so it's read as literal diff --git a/src/lib/donationVaultClient.test.tsx b/src/lib/donationVaultClient.test.tsx index e9673e5..400b0cc 100644 --- a/src/lib/donationVaultClient.test.tsx +++ b/src/lib/donationVaultClient.test.tsx @@ -90,4 +90,30 @@ describe('useDonationVaultClient', () => { expect(clientFrom).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledTimes(1); }); + + it('shows a user-friendly toast instead of the raw error, and logs the real one to the console', async () => { + const { useDonationVaultClient } = await freshDonationVaultClient(); + // Stands in for the actual failure this was reported against — a + // missing NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID throws exactly this + // developer-facing message from getDonationVaultClient. + const devError = new Error('NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID is not set'); + clientFrom.mockRejectedValue(devError); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + await vi.waitFor(() => expect(showToast).toHaveBeenCalled()); + + expect(showToast).toHaveBeenCalledWith( + 'error', + "Couldn't reach the donation contract. Please try again shortly.", + ); + expect(showToast).not.toHaveBeenCalledWith( + 'error', + expect.stringContaining('NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID'), + ); + expect(consoleError).toHaveBeenCalledWith(expect.any(String), devError); + + consoleError.mockRestore(); + }); }); diff --git a/src/lib/donationVaultClient.ts b/src/lib/donationVaultClient.ts index 54ff566..3eb4bc6 100644 --- a/src/lib/donationVaultClient.ts +++ b/src/lib/donationVaultClient.ts @@ -102,10 +102,11 @@ export function useDonationVaultClient(): { client: DonationVaultClient | null; .catch((err) => { if (!cancelled && !hasToastedInitError) { hasToastedInitError = true; - showToast( - 'error', - err instanceof Error ? `Couldn't reach the donation contract: ${err.message}` : `Couldn't reach the donation contract.`, - ); + // The error can be a developer-facing message (e.g. a missing + // NEXT_PUBLIC_*_CONTRACT_ID env var) that a donor can't act on — + // logged for whoever's debugging the deployment, not shown. + console.error('Failed to initialize the donation vault client:', err); + showToast('error', "Couldn't reach the donation contract. Please try again shortly."); } }); diff --git a/src/lib/durations.ts b/src/lib/durations.ts new file mode 100644 index 0000000..619d35d --- /dev/null +++ b/src/lib/durations.ts @@ -0,0 +1,8 @@ +/** Duration presets offered wherever a stream's length is picked (creating + * a new stream, or modifying an existing one's rate/duration). */ +export const DURATIONS = [ + { label: '1 week', seconds: 7 * 24 * 60 * 60 }, + { label: '1 month', seconds: 30 * 24 * 60 * 60 }, + { label: '3 months', seconds: 90 * 24 * 60 * 60 }, + { label: '1 year', seconds: 365 * 24 * 60 * 60 }, +]; diff --git a/src/lib/format.ts b/src/lib/format.ts index 8592101..cceb5f3 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -79,6 +79,15 @@ export function tokenSymbol(tokenAddress: string): string { return truncateAddress(tokenAddress); } +/** Like `tokenSymbol`, but for display contexts that want the token's full + * identity rather than a shortened one: "XLM" for the native asset, or the + * token's full (untruncated) contract address for anything else — there's + * no symbol/name resolution for arbitrary tokens, only the native asset's + * address is derivable client-side. */ +export function tokenLabel(tokenAddress: string): string { + return tokenAddress === getNativeAssetAddress() ? 'XLM' : tokenAddress; +} + /** `formatAmount` plus the token's symbol, so "100" can't be mistaken for * the wrong asset (e.g. "100 XLM", "100 USDC", "100 CABC…WXYZ"). */ export function formatTokenAmount( @@ -243,3 +252,15 @@ export function truncateAddress(address: string): string { } return `${address.slice(0, 4)}…${address.slice(-4)}`; } + +/** Formats an ISO timestamp as a bare date (no time), in the viewer's own + * locale and timezone. */ +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(); +} + +/** Formats an ISO timestamp as a date and time, in the viewer's own locale + * and timezone. */ +export function formatDateTime(iso: string): string { + return new Date(iso).toLocaleString(); +} diff --git a/src/lib/ngoRegistryClient.ts b/src/lib/ngoRegistryClient.ts index 5940edf..4518931 100644 --- a/src/lib/ngoRegistryClient.ts +++ b/src/lib/ngoRegistryClient.ts @@ -58,10 +58,11 @@ export function useNgoRegistryClient(): { client: NgoRegistryClient | null; read }) .catch((err) => { if (!cancelled) { - showToast( - 'error', - err instanceof Error ? `Couldn't reach the NGO registry contract: ${err.message}` : `Couldn't reach the NGO registry contract.`, - ); + // The error can be a developer-facing message (e.g. a missing + // NEXT_PUBLIC_*_CONTRACT_ID env var) that a visitor can't act on — + // logged for whoever's debugging the deployment, not shown. + console.error('Failed to initialize the NGO registry client:', err); + showToast('error', "Couldn't reach the NGO registry contract. Please try again shortly."); } }); diff --git a/src/lib/stellar.test.ts b/src/lib/stellar.test.ts index 7a68495..1491b46 100644 --- a/src/lib/stellar.test.ts +++ b/src/lib/stellar.test.ts @@ -183,28 +183,32 @@ describe('getTokenBalance', () => { expect(scValToNative).toHaveBeenCalledWith(retval); }); - it('returns null when the simulation reports an error', async () => { + it('rejects when the simulation reports an error, instead of returning null', async () => { simulateTransaction.mockResolvedValue({ error: 'contract trap' }); isSimulationError.mockReturnValue(true); const { getTokenBalance } = await freshStellar(TESTNET_PASSPHRASE); - expect(await getTokenBalance(TOKEN_ADDRESS, WALLET_ADDRESS)).toBeNull(); + await expect(getTokenBalance(TOKEN_ADDRESS, WALLET_ADDRESS)).rejects.toThrow(); expect(scValToNative).not.toHaveBeenCalled(); }); - it('returns null when the simulation succeeds but has no result', async () => { + it('rejects when the simulation succeeds but has no result', async () => { simulateTransaction.mockResolvedValue({}); const { getTokenBalance } = await freshStellar(TESTNET_PASSPHRASE); - expect(await getTokenBalance(TOKEN_ADDRESS, WALLET_ADDRESS)).toBeNull(); + await expect(getTokenBalance(TOKEN_ADDRESS, WALLET_ADDRESS)).rejects.toThrow(); expect(scValToNative).not.toHaveBeenCalled(); }); - it('returns null when simulateTransaction throws', async () => { + // The RPC outage / wrong-network case: simulateTransaction itself throws, + // as opposed to resolving with a simulation-level error above — both must + // reject distinguishably rather than collapsing to the same "null" a + // caller can't tell apart from a genuine zero balance. + it('propagates the error when simulateTransaction itself throws', async () => { simulateTransaction.mockRejectedValue(new Error('network error')); const { getTokenBalance } = await freshStellar(TESTNET_PASSPHRASE); - expect(await getTokenBalance(TOKEN_ADDRESS, WALLET_ADDRESS)).toBeNull(); + await expect(getTokenBalance(TOKEN_ADDRESS, WALLET_ADDRESS)).rejects.toThrow('network error'); expect(scValToNative).not.toHaveBeenCalled(); }); diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 5fa589d..a8ee8d8 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -95,37 +95,44 @@ const SIMULATION_SOURCE_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA * which only accepts a classic `Asset` and so can't look up an arbitrary * custom contract address. * + * Unlike most lookups in this file, failures here are not swallowed to + * `null` — a wrong network, an RPC outage and an address that just isn't a + * token contract are different problems with different fixes, and the + * caller (CreateStreamForm) needs to tell "couldn't check" apart from "this + * address has no balance()" to show a retry instead of silently showing + * nothing (issue: "getTokenBalance reports every failure as 'no balance'"). + * * @returns The raw i128 balance as a string (same units as everywhere else - * in this app — see TOKEN_DECIMALS/formatAmount), or `null` if the - * simulation fails (e.g. the address isn't a valid token contract). + * in this app — see TOKEN_DECIMALS/formatAmount). + * @throws {Error} if the simulation fails or errors for any reason (wrong + * network, RPC outage, the address isn't a valid token contract, etc.). */ export async function getTokenBalance( tokenAddress: string, walletAddress: string, -): Promise { - try { - const server = new rpc.Server(SOROBAN_RPC_URL); - const account = new Account(SIMULATION_SOURCE_ACCOUNT, '0'); - const contract = new Contract(tokenAddress); - - const tx = new TransactionBuilder(account, { - fee: SIMULATION_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation(contract.call('balance', nativeToScVal(walletAddress, { type: 'address' }))) - .setTimeout(30) - .build(); - - const simulated = await server.simulateTransaction(tx); - if (rpc.Api.isSimulationError(simulated) || !simulated.result) { - return null; - } - - const balance = scValToNative(simulated.result.retval) as bigint; - return balance.toString(); - } catch { - return null; +): Promise { + const server = new rpc.Server(SOROBAN_RPC_URL); + const account = new Account(SIMULATION_SOURCE_ACCOUNT, '0'); + const contract = new Contract(tokenAddress); + + const tx = new TransactionBuilder(account, { + fee: SIMULATION_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(contract.call('balance', nativeToScVal(walletAddress, { type: 'address' }))) + .setTimeout(30) + .build(); + + const simulated = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(simulated)) { + throw new Error(`Failed to simulate balance() for ${tokenAddress}: ${simulated.error}`); } + if (!simulated.result) { + throw new Error(`balance() simulation for ${tokenAddress} returned no result`); + } + + const balance = scValToNative(simulated.result.retval) as bigint; + return balance.toString(); } /** diff --git a/src/middleware.ts b/src/middleware.ts index a54cae1..2270773 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000'; +import { API_URL } from '@/lib/config'; + const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org';