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: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ route exists specifically to be iframed on NGOs' own sites. See
[docs/EMBED.md](./docs/EMBED.md) for the full integration guide (sizing,
security headers, and WordPress/Webflow/plain-HTML examples).

See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md) for the full step-by-step
guide to both platforms, including every `--build-arg` Docker needs and a
complete table of which `NEXT_PUBLIC_*` values are required vs. optional.

## Authentication

The platform admin panel (`/platform-admin`) has no separate login — it
Expand Down
133 changes: 133 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Deployment guide

Step-by-step instructions for deploying this app to Vercel or via the
provided `Dockerfile`, plus a full reference of every `NEXT_PUBLIC_*`
variable the app needs and when it needs it. See
[ENVIRONMENT.md](../ENVIRONMENT.md) for the day-to-day local-dev version of
this reference, and the README's [Deployment](../README.md#deployment)
section for the short version.

## Build time vs. runtime: there isn't a runtime set

Every environment variable this app reads is prefixed `NEXT_PUBLIC_`, and
Next.js inlines **every** `NEXT_PUBLIC_*` reference — in client components,
server components, and middleware alike — into the compiled output at `next
build`. There is currently no server-only (non-`NEXT_PUBLIC_`) secret and no
value read fresh from `process.env` at request time. In other words: for
this app, every variable below is a **build-time** value. Setting or
changing one after the build has already run has no effect until the app is
rebuilt — only *how* you trigger that rebuild differs between the two
platforms covered here:

- **Vercel** rebuilds the app itself on every deploy, so changing a project
env var just means triggering a redeploy (Vercel does this automatically
on a git push, or you can click "Redeploy" in the dashboard).
- **Docker** does not rebuild anything on its own. The values have to be
passed as `--build-arg`s to `docker build`, and changing any of them
means rebuilding the image — passing a new value to `docker run` (via
`-e` or `--env-file`) on an already-built image does nothing.

If a future change adds a genuine server-only secret (no `NEXT_PUBLIC_`
prefix), it would be read at request time instead, set directly in the
Vercel project's env vars or passed to `docker run` — not baked in at build
time. None of the variables below are like that today.

## Variable reference

| Variable | Required | Default / testnet value | Description |
|---|---|---|---|
| `NEXT_PUBLIC_API_URL` | No | `http://localhost:3000` | Base URL of the `streamgive-backend` API. Point this at your staging/production API. Also read by `src/middleware.ts` to allowlist it in the CSP's `connect-src`. |
| `NEXT_PUBLIC_APP_URL` | No | `http://localhost:3001` | The app's own origin. Used as a server-render fallback (`robots.ts`, `sitemap.ts`, the root layout's metadata) and as the SSR fallback for the embed snippet/QR code before the browser's real origin is available. Set it to the deployed domain for correct SEO metadata. |
| `NEXT_PUBLIC_SOROBAN_RPC_URL` | No | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint for on-chain reads and contract calls. Must match the network of the contract IDs below. Also read by `src/middleware.ts` to allowlist it in the CSP's `connect-src`. |
| `NEXT_PUBLIC_NETWORK_PASSPHRASE` | No | `Test SDF Network ; September 2015` | Stellar network passphrase. Use `Public Global Stellar Network ; September 2015` for mainnet — this also flips `IS_MAINNET`/`NETWORK_NAME` in `src/lib/stellar.ts`, which controls whether the testnet warning banner shows. |
| `NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID` | **Yes** | *(testnet value pre-filled)* | Soroban contract ID of the deployed `DonationVault` contract. Current value: `streamgive-contracts/deployments.json`. |
| `NEXT_PUBLIC_NGO_REGISTRY_CONTRACT_ID` | **Yes** | *(testnet value pre-filled)* | Soroban contract ID of the deployed `NgoRegistry` contract. Current value: `streamgive-contracts/deployments.json`. |
| `NEXT_PUBLIC_USDC_ISSUER` | No | `GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5` (testnet) | Stellar account of Circle's USDC issuer. The USDC token contract address is derived from this. Switch to the mainnet issuer when moving networks. |
| `NEXT_PUBLIC_ADMIN_ADDRESS` | No | *(unset)* | The wallet address allowed into `/platform-admin`. Purely cosmetic — it only decides whether the header shows the "Platform Admin" link; the backend independently verifies every admin request against its own `ADMIN_ADDRESS`. Leave unset and the link simply never appears. Must match the backend's `ADMIN_ADDRESS` for the link to show for the right wallet. |

"Required" above means the app is missing core functionality without it
(donations/registry calls fail), not that a build errors without it.

All contract-ID/network variables
(`NEXT_PUBLIC_SOROBAN_RPC_URL`, `NEXT_PUBLIC_NETWORK_PASSPHRASE`,
`NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID`, `NEXT_PUBLIC_NGO_REGISTRY_CONTRACT_ID`,
`NEXT_PUBLIC_USDC_ISSUER`) must refer to the same Stellar network. A
mismatch typically shows up as an XDR decode error or a "contract not
found" failure rather than a clear message.

## Deploying to Vercel

Vercel is the simpler path — it builds and runs the app itself, with no
Dockerfile involved.

1. **Import the repo.** From the Vercel dashboard, "Add New… → Project",
then select this repository. Vercel auto-detects Next.js; the default
build command (`next build`) and output settings are correct as-is —
`next.config.ts` skips the `standalone` output specifically when
`VERCEL` is set, since Vercel's own build pipeline expects the default
layout.
2. **Set the environment variables.** In the project's Settings →
Environment Variables, add each variable from the
[reference above](#variable-reference) that you need to override from
its testnet default (at minimum, the two contract IDs if you're not
using the testnet deployment). Vercel lets you scope each one per
environment (Production / Preview / Development) — e.g. point Preview
deployments at a staging backend and Production at the real one.
3. **Deploy.** Push to the connected branch, or click "Deploy" in the
dashboard. Vercel builds and serves the app; no further configuration
is needed for the security headers or the `/embed/*` CSP exemption —
`src/middleware.ts` runs the same way on Vercel's Edge Runtime as it
does anywhere else.
4. **Changed a variable?** Trigger a new deployment (a git push, or
"Redeploy" in the dashboard) — see [Build time vs.
runtime](#build-time-vs-runtime-there-isnt-a-runtime-set) above for why
this step is required.

## Deploying with Docker

Use this for self-hosting anywhere that can run a container. The
`Dockerfile` is a two-stage build: a `build` stage that compiles the app
with full `node_modules`, and a minimal `runtime` stage that only ships
Next's `standalone` output (self-contained server, not the full
dependency tree) running as a non-root user.

1. **Build the image**, passing every `NEXT_PUBLIC_*` value you need to
override as a `--build-arg` — the `Dockerfile` declares one `ARG` per
variable in the [reference above](#variable-reference), so any you
omit fall back to whatever's hardcoded as that variable's default in
the source (not the `.env.example` defaults, since `.env` is never
copied into the image — see `.dockerignore`):

```bash
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
--build-arg NEXT_PUBLIC_APP_URL=https://give.example.com \
--build-arg NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org \
--build-arg NEXT_PUBLIC_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" \
--build-arg NEXT_PUBLIC_DONATION_VAULT_CONTRACT_ID=CDLKBGX3VTFZZBVBNQYHF47PQO6I7UTBYVVQE3BPJDONF6G4U33Z7VLH \
--build-arg NEXT_PUBLIC_NGO_REGISTRY_CONTRACT_ID=CCPWQBI7FV2CZ7EECOVZ5XHOHK5S7CS6XTBDWPWHBEOKPTIASNGOBQFH \
--build-arg NEXT_PUBLIC_USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 \
--build-arg NEXT_PUBLIC_ADMIN_ADDRESS=GABCDEF... \
-t streamgive-frontend .
```

Values shown above are the testnet defaults from `.env.example` — fill
in your own backend URL, app URL and (if applicable) admin address.
`NEXT_PUBLIC_ADMIN_ADDRESS` can be omitted entirely if the deployment
has no platform admin wallet to show the link for.

2. **Run the container.** `--env-file`/`-e` here only matter for anything
read at runtime — today, nothing is, so this just publishes the port:

```bash
docker run -p 3001:3001 streamgive-frontend
```

3. **Changed a variable?** Rebuild the image with the new `--build-arg`
values and redeploy the new image — an already-built image cannot pick
up a different value for any of these by any other means.

Either platform: `/embed/*` is exempt from the `X-Frame-Options: DENY`
header the app sets everywhere else, since that route is meant to be
iframed on an NGO's own site. See [docs/EMBED.md](./EMBED.md) for the full
embedding guide.
24 changes: 24 additions & 0 deletions e2e/embed-widget.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test';

/**
* The /embed/:ngoId widget is meant to be loaded in an <iframe> on a
* partner's own site, not visited as a page in its own right — AppShell
* skips the header, footer and testnet banner for the whole /embed path
* (see src/components/common/AppShell.tsx). CI has no backend, so getNgo()
* throws and the widget renders its own inline error state rather than the
* donate form — that's still a valid check: the chrome is excluded based
* on the path alone, regardless of which render branch the page lands on.
*/
test.describe('embed widget has no site chrome', () => {
test('/embed/:ngoId omits the header, footer and testnet banner', async ({ page }) => {
await page.goto('/embed/00000000-0000-0000-0000-000000000000');

await expect(page.locator('main#main')).toBeVisible();

// <header>'s implicit landmark role is "banner" — distinct from the
// testnet EnvironmentBanner checked via role="alert" below.
await expect(page.getByRole('banner')).toHaveCount(0);
await expect(page.getByRole('contentinfo')).toHaveCount(0);
await expect(page.getByRole('alert')).toHaveCount(0);
});
});
38 changes: 38 additions & 0 deletions e2e/mobile-nav.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';

/**
* The rest of the e2e suite runs at the default desktop viewport, where
* Header renders its `md:flex` nav and never mounts the hamburger menu at
* all — so the mobile menu (`md:hidden`, toggled open/closed, closes again
* on navigation) has never actually run in a browser. Tailwind's `md`
* breakpoint is 768px (see Header.tsx), so this viewport has to stay under
* that for the hamburger to render.
*/
test.describe('mobile navigation menu', () => {
test.use({ viewport: { width: 390, height: 844 } });

test('opens on tap, follows a link, and closes itself', async ({ page }) => {
await page.goto('/');

const toggle = page.getByRole('button', { name: 'Toggle menu' });
const mobileNav = page.locator('#mobile-nav');

await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(mobileNav).toBeHidden();

await toggle.click();

await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(mobileNav).toBeVisible();

await mobileNav.getByRole('link', { name: 'Dashboard' }).click();

await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: /your donations/i })).toBeVisible();

// Following the link closes the menu — it must not still be open,
// covering the page the visitor landed on.
await expect(mobileNav).toBeHidden();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
});
36 changes: 36 additions & 0 deletions e2e/theme-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from '@playwright/test';

/**
* ThemeToggle writes its preference to localStorage and THEME_INIT_SCRIPT
* (injected into <head>, see src/lib/theme.ts and src/app/layout.tsx)
* re-applies it to <html> before hydration on every load — nothing in the
* existing suite actually reloads the page to check that round-trip.
*/
test.describe('theme preference', () => {
test('toggling to dark mode survives a reload', async ({ page }) => {
await page.goto('/');

// A fresh browser context has no stored preference, so this starts on
// 'system' — two clicks cycle it: system -> light -> dark.
const toggle = page.getByRole('button', { name: /switch to/i });

await toggle.click();
await expect(toggle).toHaveAccessibleName('Switch to dark mode');

await toggle.click();
await expect(toggle).toHaveAccessibleName('Switch to system mode');

await expect(page.locator('html')).toHaveClass(/dark/);
expect(await page.evaluate(() => localStorage.getItem('theme'))).toBe('dark');

await page.reload();

// THEME_INIT_SCRIPT re-applies the stored class before hydration, so
// this holds even before ThemeToggle itself has mounted.
await expect(page.locator('html')).toHaveClass(/dark/);

// And the toggle, once mounted, reads the same stored preference back
// rather than resetting to 'system'.
await expect(page.getByRole('button', { name: 'Switch to system mode' })).toBeVisible();
});
});
23 changes: 20 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 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 @@ -421,6 +433,12 @@ describe('DashboardPage', () => {
expect(downloaded).toHaveLength(1);
expect(downloaded[0].download).toBe('streamgive-donations.csv');
expect(downloaded[0].href).toBe('blob:streamgive-csv');

// The object URL is revoked a second after the click (not
// synchronously), so the download isn't cancelled by the URL
// disappearing too soon — see downloadDonationHistoryCsv.
expect(revokeObjectURL).not.toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:streamgive-csv');
});
});
Expand Down Expand Up @@ -530,6 +548,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
7 changes: 7 additions & 0 deletions src/app/(main)/platform-admin/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ describe('PlatformAdminPage', () => {
const approveButton = await screen.findByRole('button', { name: 'Approve' });
await user.click(approveButton);

// First click only builds the tx (for the fee estimate) and shows the
// confirm panel — nothing is signed yet.
expect(approveNgoMock).toHaveBeenCalledWith({ ngo_owner: APPLICATION.ownerAddress });
expect(signAndSend).not.toHaveBeenCalled();

const confirmButton = await screen.findByRole('button', { name: 'Yes, approve' });
await user.click(confirmButton);

expect(signAndSend).toHaveBeenCalled();
expect(reviewNgoApplication).toHaveBeenCalledWith(
ADMIN_ADDRESS,
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
3 changes: 3 additions & 0 deletions src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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;
}
Expand Down
Loading
Loading