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
52 changes: 52 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Website

on:
pull_request:
paths:
- "apps/web/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "eslint.config.mjs"
- ".npmrc"
- ".github/workflows/web.yml"
push:
branches: [main]
paths:
- "apps/web/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "eslint.config.mjs"
- ".npmrc"
- ".github/workflows/web.yml"

concurrency:
group: web-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: Typecheck, test, lint and build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Typecheck, test, lint and build the website and the packages it uses.
- run: pnpm turbo run typecheck test lint build --filter "@outhere/web..."
- name: Check every page was written
working-directory: apps/web
run: |
test -f dist/index.html && test -f dist/crews/index.html && test -f dist/black/index.html
test -f dist/privacy/index.html && test -f dist/404.html && test -f dist/sitemap.xml
test "$(ls dist/boroughs | grep -v index.html | wc -l)" -eq 32
grep -q 'Every step' dist/index.html
12 changes: 12 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Outhere website settings. Copy to .env.local for local work; set the same
# names in your host's environment variables for production.

# The Outhere Supabase project (Project settings > API). The anon key is meant
# to be public: the website tables are only reachable through db/website.sql's
# functions. Leave both empty to run the site with example figures and
# simulated sign-ups.
VITE_SUPABASE_URL=
VITE_SUPABASE_ANON_KEY=

# Used for canonical links, the sitemap and social cards.
VITE_SITE_URL=https://weouthere.app
6 changes: 6 additions & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
dist/
dist-ssr/
.env.local
.env.*.local
.vercel
60 changes: 60 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# weouthere.app

The Outhere marketing and waitlist website. It is the approved website prototype
rebuilt as a real site: React with Radix UI on Vite, every page written out as
static HTML at build time, and sign-ups saved to Outhere's Supabase project.

## Pages

| Path | What it is |
| --- | --- |
| `/` | Home: hero reel, manifesto, how it works, 1v1 battle, city vs city, UK launch map with the ten cities and the regions, membership with the feature comparison, Black, run clubs, the app, FAQ, final sign-up |
| `/crews` | Run clubs claim their official crew; finder, claim form, captain view, Founding 500 board |
| `/black` | Black membership and the invite request form |
| `/boroughs/<borough>` | One page for each of the 32 London boroughs (ids match `supabase/seed.sql`); `/boroughs` jumps to your own |
| `/privacy` | Privacy notice (draft: needs a solicitor before launch) |
| `/i/<code>` | Invite links. Shows the home page and credits the friend who shared it |

## Working on it

```bash
pnpm install # from the repo root
pnpm --filter @outhere/web dev # http://localhost:5173
pnpm --filter @outhere/web check # typecheck, unit tests and the full static build
```

Without Supabase settings the site runs on **example figures**: sign-ups, club
claims and Black requests are simulated in the browser and every made-up number
is labelled as an example. That is also how a preview without a database behaves.

## Going live

1. Apply the app's Supabase migrations, which include the website's tables and
functions (`supabase/migrations/20261015100900_website.sql`, tested by
`supabase/tests/test_website.sql`).
2. Set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` (Project settings > API)
and `VITE_SITE_URL=https://weouthere.app` in the host's environment, then build.
With them set, the figures are real and the "example" labels disappear.
3. Host the `dist` folder. On Vercel: import the repo with the root directory
left at the repo root. `vercel.json` at the repo root belongs to the website
and sets the rest: pnpm, clean URLs, invite links, caching, security headers,
and skipping builds on branches without the site or where it has not
changed. Then add the domain.

The website owns `weouthere.app`. Any path it has no page for (a club link such
as `weouthere.app/peckhampacers`, or anything else) is passed to the member app
at `app.weouthere.app`, which serves club pages and its own not-found page. The
member app keeps every top-level path the website uses in `RESERVED_SLUGS`
(`packages/core/src/rules.ts`), and a test here fails if the two drift apart.

Other hosts work too: serve `dist` with clean URLs (`/crews` serves
`crews/index.html`), rewrite `/i/*` to `/`, and send other paths to the member
app (or to `404.html` without one).

## Where things live

- `src/pages`: one file per page; `src/components`: nav, footer, waitlist dialog, Radix-based kit (tabs, accordion, tooltip, London borough map), place picker, hero reel
- `src/content`: words and settings that change most (FAQ, feature table, reel cut list, prices, social links, app demo link)
- `src/styles/site.css`: the prototype's stylesheet, kept as is, plus a small Radix block at the end
- `public/img`: the Higgsfield photography and 3D objects from the prototype
- The database side (waitlist, club claims and Black requests, reachable only through four functions) lives in `supabase/migrations/20261015100900_website.sql`. Places are UK-wide: a launch city (plus the borough in London) or a town and its region (`src/lib/places.ts`), matching `supabase/migrations/20261015101400_website_uk.sql`
17 changes: 17 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#07090c">
<meta name="color-scheme" content="dark">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<!--head-->
</head>
<body>
<div id="root"><!--app--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
41 changes: 41 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@outhere/web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "weouthere.app: the Outhere marketing and waitlist website",
"engines": {
"node": ">=20.19"
},
"scripts": {
"dev": "vite",
"build": "vite build && vite build --ssr src/entry-server.tsx --outDir dist-ssr && node scripts/prerender.mjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"check": "pnpm typecheck && pnpm test && pnpm build",
"lint": "eslint ."
},
"dependencies": {
"@fontsource/barlow-condensed": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@outhere/core": "workspace:*",
"@radix-ui/react-accordion": "^1.3.1",
"@radix-ui/react-dialog": "^1.3.1",
"@radix-ui/react-popover": "^1.3.1",
"@radix-ui/react-tabs": "^1.2.1",
"@radix-ui/react-visually-hidden": "^1.2.13",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.2",
"typescript": "^5.9.3",
"vite": "^8.3.4",
"vitest": "^4.0.0"
}
}
Binary file added apps/web/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/web/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/battle.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/birmingham.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/black.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/blackcard.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/borough.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/bridge.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/captain.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/club.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/coin.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/crest.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/crew.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m10.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m3.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m4.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m5.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m6.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m7.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/cut/m9.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/img/dawn.jpg
Binary file added apps/web/public/img/face.jpg
Binary file added apps/web/public/img/heath.jpg
Binary file added apps/web/public/img/hero.jpg
Binary file added apps/web/public/img/hill.jpg
Binary file added apps/web/public/img/jacket.jpg
Binary file added apps/web/public/img/lone.jpg
Binary file added apps/web/public/img/manc.jpg
Binary file added apps/web/public/img/medal.jpg
Binary file added apps/web/public/img/phone.jpg
Binary file added apps/web/public/img/procard.jpg
Binary file added apps/web/public/img/roof.jpg
Binary file added apps/web/public/img/streaks.jpg
Binary file added apps/web/public/img/sun.jpg
Binary file added apps/web/public/img/tealclub.jpg
Binary file added apps/web/public/img/tealface.jpg
Binary file added apps/web/public/img/tealside.jpg
Binary file added apps/web/public/img/trophy.jpg
Binary file added apps/web/public/og.jpg
13 changes: 13 additions & 0 deletions apps/web/public/site.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "Outhere",
"short_name": "Outhere",
"description": "Every step counts for your crew, your borough and your city.",
"start_url": "/",
"display": "standalone",
"background_color": "#07090c",
"theme_color": "#07090c",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
28 changes: 28 additions & 0 deletions apps/web/scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* global process, console */
// Writes every route as static HTML (dist/<path>/index.html) from the SSR build,
// plus the sitemap. Run by `pnpm build` after the client and server builds.
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const dist = join(root, 'dist');
const ssr = join(root, 'dist-ssr', 'entry-server.js');
const site = (process.env.VITE_SITE_URL || 'https://weouthere.app').replace(/\/$/, '');

const { render, ROUTES } = await import(pathToFileURL(ssr).href);
const template = readFileSync(join(dist, 'index.html'), 'utf8');

for (const route of ROUTES) {
const { html, head } = render(route);
const page = template.replace('<!--head-->', head).replace('<!--app-->', html);
const file = route.page === 'notfound' ? join(dist, '404.html') : join(dist, route.path, 'index.html');
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, page);
}

const urls = ROUTES.filter((r) => r.page !== 'notfound' && r.path !== '/boroughs').map((r) => ` <url><loc>${site}${r.path === '/' ? '/' : r.path}</loc></url>`);
writeFileSync(join(dist, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`);
writeFileSync(join(dist, 'robots.txt'), `User-agent: *\nAllow: /\n\nSitemap: ${site}/sitemap.xml\n`);
rmSync(join(root, 'dist-ssr'), { recursive: true, force: true });
console.log(`prerendered ${ROUTES.length} pages`);
44 changes: 44 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { Footer, Grain, Nav, useLanding, useReveal } from './components/Chrome';
import { SiteProvider } from './components/Site';
import { local } from './lib/format';
import { isBorough, slug } from './lib/boroughs';
import Black from './pages/Black';
import Borough from './pages/Borough';
import Crews from './pages/Crews';
import Home from './pages/Home';
import NotFound from './pages/NotFound';
import Privacy from './pages/Privacy';
import type { Route } from './routes';

function Page({ route }: { route: Route }) {
useReveal();
useLanding();
useEffect(() => {
// The bare /boroughs page jumps to your own borough once you have picked one.
if (route.path === '/boroughs') {
const me = local.get<{ borough?: string; place?: { city?: string; borough?: string } }>('me');
const b = me?.place ? (me.place.city === 'london' ? me.place.borough : undefined) : me?.borough;
if (isBorough(b) && b !== route.borough) location.replace(`/boroughs/${slug(b)}`);
}
}, [route]);
switch (route.page) {
case 'home': return <Home />;
case 'crews': return <Crews />;
case 'black': return <Black />;
case 'privacy': return <Privacy />;
case 'borough': return <Borough borough={route.borough!} />;
default: return <NotFound />;
}
}

export default function App({ route }: { route: Route }) {
return (
<SiteProvider defaultPlace={route.borough ? { city: 'london', borough: route.borough } : undefined}>
<Nav page={route.page} />
<Page route={route} />
<Footer />
<Grain />
</SiteProvider>
);
}
Loading
Loading