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)/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..de497bd 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -1,25 +1,23 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; import { HowItWorks } from '@/components/landing/HowItWorks'; import { StatsStrip } from '@/components/landing/StatsStrip'; -export default function Home() { +export default async function Home() { + const t = await getTranslations('HomePage'); + 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/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.tsx b/src/components/dashboard/StreamControls.tsx index abbe0b3..073d5f2 100644 --- a/src/components/dashboard/StreamControls.tsx +++ b/src/components/dashboard/StreamControls.tsx @@ -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 diff --git a/src/components/donate/CreateStreamForm.tsx b/src/components/donate/CreateStreamForm.tsx index 34f670b..5f10dd1 100644 --- a/src/components/donate/CreateStreamForm.tsx +++ b/src/components/donate/CreateStreamForm.tsx @@ -7,6 +7,7 @@ import { ConnectWalletPrompt } from '@/components/common/ConnectWalletPrompt'; import { useToast } from '@/components/toast/ToastProvider'; import { useWallet } from '@/components/wallet/WalletProvider'; import { useDonationVaultClient } from '@/lib/donationVaultClient'; +import { DURATIONS } from '@/lib/durations'; import { describeAmountError, formatAmount, @@ -28,14 +29,6 @@ import { } from '@/lib/stellar'; import { describeContractError } from '@/lib/contractTypes'; - -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 }, -]; - // Quick-fill shortcuts for the "Total amount" field, in whichever token is // currently selected (the field isn't pinned to a single currency, so // these are unitless multipliers rather than e.g. "$5"). diff --git a/src/components/landing/HowItWorks.tsx b/src/components/landing/HowItWorks.tsx index f4f88d0..baa1409 100644 --- a/src/components/landing/HowItWorks.tsx +++ b/src/components/landing/HowItWorks.tsx @@ -1,24 +1,16 @@ -const 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.', - }, -]; +import { getTranslations } from 'next-intl/server'; + +type Step = { title: string; description: string }; + +export async function HowItWorks() { + const t = await getTranslations('HowItWorks'); + // Steps are a JSON array, not a single string — t.raw() returns the + // message as-is instead of running it through ICU formatting. + const steps = t.raw('steps') as Step[]; -export function HowItWorks() { return (
-

How it works

+

{t('heading')}

    {steps.map((step, index) => (
  1. diff --git a/src/components/layout/Header.test.tsx b/src/components/layout/Header.test.tsx index 3e22250..d9fd120 100644 --- a/src/components/layout/Header.test.tsx +++ b/src/components/layout/Header.test.tsx @@ -8,6 +8,14 @@ const mockUsePathname = vi.fn(() => '/ngos'); vi.mock('next/navigation', () => ({ usePathname: () => mockUsePathname(), + useRouter: () => ({ refresh: vi.fn() }), +})); + +// Just enough for LocaleSwitcher (rendered inside Header) to not crash for +// lack of a real NextIntlClientProvider context. +vi.mock('next-intl', () => ({ + useLocale: () => 'en', + useTranslations: () => (key: string) => key, })); vi.mock('@/components/wallet/WalletProvider', () => ({ diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 57f008e..9c2aabf 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; +import { LocaleSwitcher } from '@/components/layout/LocaleSwitcher'; import { Logo } from '@/components/layout/Logo'; import { ThemeToggle } from '@/components/layout/ThemeToggle'; import { ConnectWalletButton } from '@/components/wallet/ConnectWalletButton'; @@ -33,6 +34,9 @@ export function Header() { useEffect(() => { if (!address) { + // A disconnected wallet must not keep showing the NGO Admin link from + // a previous session's lookup. + // eslint-disable-next-line react-hooks/set-state-in-effect setOwnsNgo(false); return; } @@ -103,6 +107,7 @@ export function Header() { {link.label} ))} + @@ -157,6 +162,7 @@ export function Header() { ); })} + )} diff --git a/src/components/layout/LocaleSwitcher.tsx b/src/components/layout/LocaleSwitcher.tsx new file mode 100644 index 0000000..01453ad --- /dev/null +++ b/src/components/layout/LocaleSwitcher.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; + +import { LOCALE_COOKIE, LOCALE_LABELS, LOCALES, type Locale } from '@/i18n/config'; + +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; + +/** + * Scaffolding for later real translations (issue: "Add i18n scaffolding") — + * only `messages/en.json` and `messages/es.json` exist today, and only the + * landing page reads from them (see HomePage and HowItWorks). Switching + * still works end to end (it writes the NEXT_LOCALE cookie `request.ts` + * reads and refreshes the page), it just won't change anything outside + * those two components yet. See docs/I18N.md. + */ +export function LocaleSwitcher() { + const locale = useLocale(); + const t = useTranslations('LocaleSwitcher'); + const router = useRouter(); + + function handleChange(event: React.ChangeEvent) { + const next = event.target.value as Locale; + document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}`; + router.refresh(); + } + + return ( + + ); +} diff --git a/src/components/ngoAdmin/WithdrawalHistory.tsx b/src/components/ngoAdmin/WithdrawalHistory.tsx index cebc7e1..0dcba5f 100644 --- a/src/components/ngoAdmin/WithdrawalHistory.tsx +++ b/src/components/ngoAdmin/WithdrawalHistory.tsx @@ -3,14 +3,10 @@ import { useEffect, useState } from 'react'; import { getWithdrawals, type Withdrawal } from '@/lib/api'; -import { formatAmount } from '@/lib/format'; +import { formatAmount, formatDateTime } from '@/lib/format'; const PAGE_SIZE = 10; -function formatDate(iso: string): string { - return new Date(iso).toLocaleString(); -} - /** * Past withdrawal transactions for an NGO, so admins can reconcile their * records against what they've actually pulled out of StreamGive. @@ -123,7 +119,7 @@ export function WithdrawalHistory({ ngoId }: { ngoId: string }) { {visibleWithdrawals.map((withdrawal) => ( - {formatDate(withdrawal.createdAt)} + {formatDateTime(withdrawal.createdAt)} #{withdrawal.streamId} diff --git a/src/components/streams/StreamDetailsModal.test.tsx b/src/components/streams/StreamDetailsModal.test.tsx index c1b29d1..df0c9c6 100644 --- a/src/components/streams/StreamDetailsModal.test.tsx +++ b/src/components/streams/StreamDetailsModal.test.tsx @@ -131,8 +131,10 @@ describe('StreamDetailsModal', () => { it('wraps Tab from the last focusable element back to the first', () => { render(); const closeButton = screen.getByLabelText('Close'); - const lastLink = screen.getByRole('link', { name: 'View contract' }); - lastLink.focus(); + // The on-chain ID's CopyAddressButton is the last focusable element — + // it comes after "View contract" in the dialog's own tab order. + const lastControl = screen.getByRole('button', { name: `Copy address ${MOCK_STREAM.onChainId}` }); + lastControl.focus(); const notPrevented = fireEvent.keyDown(document, { key: 'Tab' }); @@ -143,12 +145,12 @@ describe('StreamDetailsModal', () => { it('wraps Shift+Tab from the first focusable element to the last', () => { render(); const closeButton = screen.getByLabelText('Close'); - const lastLink = screen.getByRole('link', { name: 'View contract' }); + const lastControl = screen.getByRole('button', { name: `Copy address ${MOCK_STREAM.onChainId}` }); expect(closeButton).toHaveFocus(); const notPrevented = fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); - expect(lastLink).toHaveFocus(); + expect(lastControl).toHaveFocus(); expect(notPrevented).toBe(false); }); diff --git a/src/components/streams/StreamDetailsModal.tsx b/src/components/streams/StreamDetailsModal.tsx index 7b00a74..90ec1dd 100644 --- a/src/components/streams/StreamDetailsModal.tsx +++ b/src/components/streams/StreamDetailsModal.tsx @@ -3,19 +3,10 @@ import Link from 'next/link'; import { useEffect, useRef } from 'react'; +import { CopyAddressButton } from '@/components/common/CopyAddressButton'; import type { Stream } from '@/lib/api'; -import { formatTokenAmount } from '@/lib/format'; -import { DONATION_VAULT_CONTRACT_ID, explorerUrl, getNativeAssetAddress } from '@/lib/stellar'; - -function formatDate(iso: string): string { - return new Date(iso).toLocaleString(); -} - -// 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; -} +import { formatDateTime, formatTokenAmount, tokenLabel } from '@/lib/format'; +import { DONATION_VAULT_CONTRACT_ID, explorerUrl } from '@/lib/stellar'; export function StreamDetailsModal({ stream, onClose }: { stream: Stream; onClose: () => void }) { useEffect(() => { @@ -125,7 +116,7 @@ export function StreamDetailsModal({ stream, onClose }: { stream: Stream; onClos {DONATION_VAULT_CONTRACT_ID && (
    Contract
    -
    +
    View contract +
    )} @@ -178,15 +172,18 @@ export function StreamDetailsModal({ stream, onClose }: { stream: Stream; onClos
    Created
    -
    {formatDate(stream.createdAt)}
    +
    {formatDateTime(stream.createdAt)}
    Last updated
    -
    {formatDate(stream.updatedAt)}
    +
    {formatDateTime(stream.updatedAt)}
    On-chain ID
    -
    {stream.onChainId}
    +
    + {stream.onChainId} + +
    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/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/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';