Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down
95 changes: 95 additions & 0 deletions docs/I18N.md
Original file line number Diff line number Diff line change
@@ -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/<code>.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 `<Link>`, 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/<code>.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/<code>.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.
27 changes: 27 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
27 changes: 27 additions & 0 deletions messages/es.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 4 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -14,4 +17,4 @@ const nextConfig: NextConfig = {
output: process.env.VERCEL ? undefined : 'standalone',
};

export default nextConfig;
export default withNextIntl(nextConfig);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions src/app/(main)/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ describe('DashboardPage', () => {
signTransaction: vi.fn(),
signMessage: vi.fn(),
networkMismatch: false,
noWalletOptions: null,
dismissNoWalletPrompt: vi.fn(),
});
}

Expand All @@ -85,6 +87,8 @@ describe('DashboardPage', () => {
signTransaction: vi.fn(),
signMessage: vi.fn(),
networkMismatch: false,
noWalletOptions: null,
dismissNoWalletPrompt: vi.fn(),
});

let resolveFetch: (streams: Stream[]) => void = () => {};
Expand Down Expand Up @@ -115,6 +119,8 @@ describe('DashboardPage', () => {
signTransaction: vi.fn(),
signMessage: vi.fn(),
networkMismatch: false,
noWalletOptions: null,
dismissNoWalletPrompt: vi.fn(),
});

render(<DashboardPage />);
Expand All @@ -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'));
Expand Down Expand Up @@ -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(<DashboardPage />);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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]);
}
Expand Down
32 changes: 26 additions & 6 deletions src/app/(main)/ngos/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,9 +33,33 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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'],
},
};
}

Expand Down
16 changes: 7 additions & 9 deletions src/app/(main)/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<main id="main">
<section className="flex flex-col items-center gap-6 px-6 py-24 text-center sm:px-12">
<h1 className="max-w-2xl text-4xl font-bold sm:text-5xl">
Give as a stream, not a one-time click.
</h1>
<p className="max-w-xl text-gray-600 dark:text-gray-400">
StreamGive lets you support verified NGOs on Stellar with continuous, cancel-anytime
donations — transparent, on-chain, and instant.
</p>
<h1 className="max-w-2xl text-4xl font-bold sm:text-5xl">{t('heroHeading')}</h1>
<p className="max-w-xl text-gray-600 dark:text-gray-400">{t('heroSubtext')}</p>
<Link
href="/ngos"
className="rounded-md bg-black px-6 py-3 text-sm font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
>
Explore NGOs
{t('exploreNgos')}
</Link>
</section>
<StatsStrip />
Expand Down
15 changes: 12 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
<html lang="en" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<head>
{/* Must run synchronously, before paint, to avoid a flash of the
wrong theme — see THEME_INIT_SCRIPT's own comment. */}
Expand All @@ -73,7 +80,9 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} bg-white text-gray-900 antialiased dark:bg-gray-950 dark:text-gray-100`}
>
<AppShell>{children}</AppShell>
<NextIntlClientProvider locale={locale} messages={messages}>
<AppShell>{children}</AppShell>
</NextIntlClientProvider>
</body>
</html>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/common/ConnectWalletPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ describe('ConnectWalletPrompt', () => {
signTransaction: vi.fn(),
signMessage: vi.fn(),
networkMismatch: false,
noWalletOptions: null,
dismissNoWalletPrompt: vi.fn(),
});
});

Expand Down
Loading
Loading