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.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/landing/StatsStripSkeleton.tsx b/src/components/landing/StatsStripSkeleton.tsx new file mode 100644 index 0000000..3a03200 --- /dev/null +++ b/src/components/landing/StatsStripSkeleton.tsx @@ -0,0 +1,23 @@ +const STAT_COUNT = 3; + +/** Suspense fallback for StatsStrip, shown while its stats request is in + * flight — same shape (a bordered strip of label/value pairs) so nothing + * jumps when the real numbers replace it. */ +export function StatsStripSkeleton() { + return ( +
    + Loading platform stats… +
    + {Array.from({ length: STAT_COUNT }, (_, i) => ( +
    +
    +
    +
    + ))} +
    +
    + ); +} diff --git a/src/components/layout/Footer.test.tsx b/src/components/layout/Footer.test.tsx index 770d28c..be81e35 100644 --- a/src/components/layout/Footer.test.tsx +++ b/src/components/layout/Footer.test.tsx @@ -4,19 +4,41 @@ import { describe, expect, it } from 'vitest'; import { Footer } from './Footer'; describe('Footer', () => { - it('links to the GitHub organization and docs in a new tab', () => { + it('links to the GitHub organization and docs in a new tab, with an accessible cue', () => { render(