From fed5ca9f70c50351f910bf04404953ac89761502 Mon Sep 17 00:00:00 2001 From: CodedColder Date: Fri, 23 Oct 2026 07:13:20 +0100 Subject: [PATCH 1/3] fixed all issues --- README.md | 4 +- docs/I18N.md | 95 ++ messages/en.json | 27 + messages/es.json | 27 + next.config.ts | 5 +- package-lock.json | 1088 +++++++++++++++-- package.json | 1 + src/app/(main)/dashboard/page.test.tsx | 14 + src/app/(main)/ngos/[id]/page.tsx | 32 +- src/app/(main)/page.tsx | 16 +- src/app/layout.tsx | 15 +- .../common/ConnectWalletPrompt.test.tsx | 2 + src/components/dashboard/StreamControls.tsx | 8 +- src/components/donate/CreateStreamForm.tsx | 9 +- src/components/landing/HowItWorks.tsx | 28 +- src/components/layout/Header.test.tsx | 8 + src/components/layout/Header.tsx | 6 + src/components/layout/LocaleSwitcher.tsx | 43 + src/components/ngoAdmin/WithdrawalHistory.tsx | 8 +- .../streams/StreamDetailsModal.test.tsx | 10 +- src/components/streams/StreamDetailsModal.tsx | 33 +- .../wallet/ConnectWalletButton.test.tsx | 4 + src/components/wallet/ConnectWalletButton.tsx | 5 +- src/i18n/config.ts | 24 + src/i18n/request.ts | 22 + src/lib/adminApi.ts | 3 +- src/lib/api.test.ts | 24 +- src/lib/api.ts | 32 +- src/lib/config.ts | 3 + src/lib/csv.ts | 9 +- src/lib/durations.ts | 8 + src/lib/format.ts | 21 + src/middleware.ts | 3 +- 33 files changed, 1400 insertions(+), 237 deletions(-) create mode 100644 docs/I18N.md create mode 100644 messages/en.json create mode 100644 messages/es.json create mode 100644 src/components/layout/LocaleSwitcher.tsx create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/request.ts create mode 100644 src/lib/durations.ts 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-lock.json b/package-lock.json index 6f9e795..973da1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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" @@ -26,6 +27,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", + "@vitest/coverage-v8": "^5.0.0", "eslint": "^9.39.5", "eslint-config-next": "^16.3.4", "eslint-config-prettier": "^10.1.8", @@ -429,6 +431,16 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -843,6 +855,31 @@ "node": ">=20.19.0" } }, + "node_modules/@eloqnt/config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@eloqnt/config/-/config-0.1.1.tgz", + "integrity": "sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g==", + "license": "MIT" + }, + "node_modules/@eloqnt/format-json": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eloqnt/format-json/-/format-json-0.1.0.tgz", + "integrity": "sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A==", + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.0" + } + }, + "node_modules/@eloqnt/format-po": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@eloqnt/format-po/-/format-po-0.1.1.tgz", + "integrity": "sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw==", + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.1", + "po-parser": "^2.2.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1095,6 +1132,36 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.22", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.22.tgz", + "integrity": "sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.14.tgz", + "integrity": "sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7" + } + }, "node_modules/@hot-wallet/sdk": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@hot-wallet/sdk/-/sdk-1.0.11.tgz", @@ -2485,6 +2552,316 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@phosphor-icons/webcomponents": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@phosphor-icons/webcomponents/-/webcomponents-2.1.5.tgz", @@ -3031,6 +3408,12 @@ "node": ">=16" } }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", @@ -4299,59 +4682,315 @@ "stellar-js": "bin/stellar-js" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.12.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@swc/core": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.20.tgz", + "integrity": "sha512-zcdfS7k28OJIMuLhqRRyGja9PxrC2GXhFXkbQ1X15y6uZEMsTrqyej55V1DiJtlXaDOmwSLn5222YPT9ZoFxPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.28" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.16.20", + "@swc/core-darwin-x64": "1.16.20", + "@swc/core-linux-arm-gnueabihf": "1.16.20", + "@swc/core-linux-arm64-gnu": "1.16.20", + "@swc/core-linux-arm64-musl": "1.16.20", + "@swc/core-linux-ppc64-gnu": "1.16.20", + "@swc/core-linux-s390x-gnu": "1.16.20", + "@swc/core-linux-x64-gnu": "1.16.20", + "@swc/core-linux-x64-musl": "1.16.20", + "@swc/core-win32-arm64-msvc": "1.16.20", + "@swc/core-win32-ia32-msvc": "1.16.20", + "@swc/core-win32-x64-msvc": "1.16.20" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.20.tgz", + "integrity": "sha512-i7NFSDnuE6tdBvBgmoVKKmburgcwZt/FZK2IusJaVGrjFrMUC1hrgE3RI+Kt9RhYM7vHvQj0W9j53zSxKEMbWQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.20.tgz", + "integrity": "sha512-ho/LkT1Q3beSBeZGNZnm1mVbh5bKZHC0UGHtK3MswqOylbHtZoMXJBL40N8JpDj5SBxwECmdNEyXnU9HJ4F6RA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.20.tgz", + "integrity": "sha512-xm3/s3zp9WyNgB1Sl78tpH76MHgobI3TSenTY15bkSHyrdQa9W2MUSESHRPd7CHGFLfpWl/0jwwNp5+hwG0e9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.20.tgz", + "integrity": "sha512-e6Um0U+VcTQyurOXhbratRQlrPITSRwugG00fhICbi2MGTi1vRiE4TMqGCjWpR1zPmZKXDGMw+yNFvh7AAgT2Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.20.tgz", + "integrity": "sha512-ekCO2obfK9NFwXGHfAQjjuU5mLDDC7qMaUJH/V/4iyzD0BqNhilr6McZrXLytOQ/xKtTNxLQ71wYrD3M2LW8Kg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.20.tgz", + "integrity": "sha512-6/x2K3l4GNUWyaSmkywnth6ipZISzhVzOLsI66bj12/hSQYaKAyg9MKTwnuFf2B/iYA6GrjDghk36RYghRameQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.20.tgz", + "integrity": "sha512-1GTd8sqS/NpcF+Y3YDSE0bT31V61+hD7ZZWEsVyiyxk/HQt2zpk4zlJJHOASBLc+/CyUuBZ949n6QMszbIXkmQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.20.tgz", + "integrity": "sha512-XtMeYAgQa5UZhAX84TXn0sXPmhf1xHwcv0cl5hHiBXxTVbplY7WiTwIQU9uOtVm3VUqL/quyCm78/umUNZ7FmA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@stellar/stellar-sdk/node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "license": "MIT", + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.20.tgz", + "integrity": "sha512-qeF3UILa+MA4zJ29CdQ8KE7YbGFPMQBxWo3Lh0Ed0ETFEJ8pCKISWo7wQG4/SmfM9HX4KT1Odoj6ixyBn6Ji2Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } + "node": ">=10" } }, - "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.20.tgz", + "integrity": "sha512-ciMtHXAmE+jnrWWO14rErr6tfK34QZm8jZRSdRLPodqJlzrYDw7TqN60HC8Yim5Ua9a1r/poyfkpNft6GRXvxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=10" } }, - "node_modules/@stellar/stellar-sdk/node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.20.tgz", + "integrity": "sha512-MTbkgGooWebmjmATx1+QRECVZ+q4ICKNXCutwOrFVD5b5hdxla/yV4e6cRFrfNMowejaNpmMQg31ENWt0Yfzpg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@stellar/stellar-sdk/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.16.20", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.20.tgz", + "integrity": "sha512-Lf5oMaSdJBImtGhDecasxcIa7Z5LeJ4aGmBOY7TScroO+A/MxxB9PslIpgV0rtG/FcxtipN7hveBg9ZTeboaaw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=20" + "node": ">=10" } }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -4361,6 +5000,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -5680,23 +6328,75 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", + "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.5", + "vitest": "5.0.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@vitest/mocker": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", - "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.0", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", - "magic-string": "^1.2.3" + "magic-string": "^1.4.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "msw": "^2.4.9", + "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -5709,19 +6409,19 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -6768,6 +7468,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7157,9 +7876,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -7275,6 +7994,19 @@ "dev": true, "license": "MIT" }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -7610,7 +8342,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -9257,6 +9988,21 @@ "ms": "^2.0.0" } }, + "node_modules/icu-minify": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.14.9.tgz", + "integrity": "sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -9351,6 +10097,31 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "11.2.15", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.15.tgz", + "integrity": "sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.18" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.18.tgz", + "integrity": "sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.11" + } + }, + "node_modules/intl-messageformat/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", + "license": "MIT" + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -9545,7 +10316,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9600,7 +10370,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -10602,6 +11371,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10877,6 +11658,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/next": { "version": "16.3.4", "resolved": "https://registry.npmjs.org/next/-/next-16.3.4.tgz", @@ -10930,6 +11727,46 @@ } } }, + "node_modules/next-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.14.9.tgz", + "integrity": "sha512-uauRtUC+TrYNA1OL/QcwL9irndLrI9jK3Wdaa9W0B5AeKh9cqVdNeT0IWgLnhBqBRrVwppVdI0M+C1+JdGPQ3w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.1", + "@eloqnt/format-json": "^0.1.0", + "@eloqnt/format-po": "^0.1.1", + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "~1.16.0", + "icu-minify": "^4.14.9", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "4.14.9", + "use-intl": "^4.14.9" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.14.9.tgz", + "integrity": "sha512-8jAkmDwQJo6RDavBHm9QRl1YTn2WXf+rI4SVhMaZxZcxopnwZic6fKTXOoNIo3kzC+a6vJySkOkXmHTVFLAYtg==", + "license": "MIT" + }, "node_modules/next/node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -11191,9 +12028,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -11477,6 +12314,12 @@ "node": ">=10.13.0" } }, + "node_modules/po-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.2.0.tgz", + "integrity": "sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12372,13 +13215,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/slow-redact": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", @@ -12431,13 +13267,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -12449,9 +13278,9 @@ } }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -12773,9 +13602,9 @@ } }, "node_modules/tinybench": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", - "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, "license": "MIT", "engines": { @@ -12783,9 +13612,9 @@ } }, "node_modules/tinyexec": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", - "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -12840,6 +13669,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.4.12", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", @@ -13352,6 +14191,33 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.14.9.tgz", + "integrity": "sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.14.9", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/utf-8-validate": { "version": "6.0.6", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", @@ -13914,25 +14780,25 @@ } }, "node_modules/vitest": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", - "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.0", - "chai": "^6.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", - "magic-string": "^1.2.3", - "obug": "^2.1.4", + "magic-string": "^1.4.3", + "obug": "^3.0.0", "picomatch": "^4.0.7", - "std-env": "^4.2.0", - "tinybench": "6.1.4", - "tinyexec": "1.3.0", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", - "why-is-node-running": "^2.3.0" + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" @@ -13947,12 +14813,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.0", - "@vitest/browser-preview": "5.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.0", - "@vitest/coverage-v8": "5.0.0", - "@vitest/ui": "5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" @@ -13997,13 +14863,13 @@ } }, "node_modules/vitest/node_modules/magic-string": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", - "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/vitest/node_modules/picomatch": { @@ -14197,20 +15063,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/word-wrap": { 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'; From 2030eaf5f580e8f0cb105610558a848821a4e782 Mon Sep 17 00:00:00 2001 From: MubyLateef Date: Fri, 23 Oct 2026 07:42:33 +0100 Subject: [PATCH 2/3] fixed all issues --- src/app/(main)/apply/page.tsx | 6 +++ src/app/(main)/page.tsx | 6 ++- src/app/(main)/privacy/page.tsx | 42 ++++++++++++++++ src/app/(main)/terms/page.tsx | 48 +++++++++++++++++++ src/components/landing/StatsStripSkeleton.tsx | 23 +++++++++ src/components/layout/Footer.test.tsx | 40 ++++++++++++---- src/components/layout/Footer.tsx | 30 +++++++++++- src/components/ngos/NgoExplorer.test.tsx | 37 ++++++++++++++ src/components/ngos/NgoExplorer.tsx | 8 +++- 9 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 src/app/(main)/privacy/page.tsx create mode 100644 src/app/(main)/terms/page.tsx create mode 100644 src/components/landing/StatsStripSkeleton.tsx 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)/page.tsx b/src/app/(main)/page.tsx index de497bd..7979b40 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -1,8 +1,10 @@ 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'); @@ -20,7 +22,9 @@ export default async function Home() { {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/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(