diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b1d278..44627f3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,6 +80,9 @@ jobs:
- name: Build web console
working-directory: web
run: npm run build
+ - name: Build product page and demo
+ working-directory: web
+ run: npm run build:pages
- name: Install Playwright browser
working-directory: web
run: npx playwright install --with-deps chromium
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..df487e4
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,47 @@
+name: Pages
+
+on:
+ push:
+ branches: [main]
+ paths: ["web/**", ".github/workflows/pages.yml"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v7.0.1
+ - uses: actions/setup-node@v6.0.0
+ with:
+ node-version: 24
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - run: npm ci
+ - name: Install Playwright browser
+ run: npx playwright install --with-deps chromium
+ - name: Capture app screenshots
+ run: npm run screenshots
+ - name: Build product page and demo
+ run: npm run build:pages
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v4
+ with:
+ path: web/dist-pages
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 969a1e3..5b7489c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ secret.env
/web/node_modules/
/web/dist/
/web/dist-demo/
+/web/dist-pages/
diff --git a/README.md b/README.md
index e530d2d..8420e21 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
Raidy is a raid scheduling project for Discord statics: the web console is where everyone plans, and Discord carries the announcements. One Go process runs the Discord bot, web API, scheduler, and production web assets, backed by PostgreSQL.
-**Status:** `v0.0.17` is an early development snapshot.
+**Status:** `v0.0.18` is an early development snapshot.
```text
backend/ Go service, Discord interactions, web API, PostgreSQL migrations
@@ -55,6 +55,10 @@ The Vite development server opens at `http://localhost:5173` and proxies API cal
The Go process applies pinned, embedded Goose migrations before opening the application store. Existing local databases are upgraded in place.
+## Product page and demo
+
+`web/product/` is the public product page, and `npm run build:demo` builds the real app against in-browser sample data. `npm run screenshots` captures the app screenshots the page shows, and `npm run build:pages` builds both into `web/dist-pages`. The Pages workflow deploys that on every merge to `main`.
+
## Run on local Kubernetes
The production image packages the Go service and built web console. The local Kustomize overlay runs it with a persistent PostgreSQL instance on k3d and exposes it through `kubectl port-forward`; see [deployment/README.md](deployment/README.md).
diff --git a/web/.prettierignore b/web/.prettierignore
index c5267d6..e44ecc0 100644
--- a/web/.prettierignore
+++ b/web/.prettierignore
@@ -5,3 +5,4 @@ src/components/ui
test-results
playwright-report
dist-demo
+dist-pages
diff --git a/web/e2e/product.spec.ts b/web/e2e/product.spec.ts
new file mode 100644
index 0000000..b9e7d15
--- /dev/null
+++ b/web/e2e/product.spec.ts
@@ -0,0 +1,31 @@
+import { devices, expect, test } from "@playwright/test";
+
+const monday = new Date("2026-11-02T00:00:00Z");
+
+test("the product page leads to the live demo and the code", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText("See which nights your whole static can raid.");
+ await expect(page.getByRole("link", { name: "Try the live demo" })).toHaveAttribute("href", "demo/");
+ await expect(page.getByRole("link", { name: "View on GitHub" }).first()).toHaveAttribute(
+ "href",
+ "https://github.com/joshchen-dev/raidy"
+ );
+});
+
+test("tapping a night in the hero updates the Discord post", async ({ page }) => {
+ await page.clock.setFixedTime(monday);
+ await page.goto("/");
+ const post = page.getByRole("figure", { name: "The Raidy post in Discord" });
+ await expect(post).toContainText("8/8 available · Confirmed");
+ await page.getByRole("button", { name: "Available on Wed 4" }).click();
+ await expect(post).toContainText("7/8 available · Attention required");
+ await expect(page.getByTestId("available-count").first()).toHaveText("7/8");
+});
+
+test("the page never scrolls sideways on a phone", async ({ browser }) => {
+ const page = await browser.newPage({ ...devices["Pixel 7"] });
+ await page.goto("http://127.0.0.1:5184/");
+ await expect(page.getByRole("figure", { name: "The Raidy post in Discord" })).toBeVisible();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
+ expect(overflow).toBeLessThanOrEqual(0);
+});
diff --git a/web/package-lock.json b/web/package-lock.json
index e2fd5ce..e3079fc 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "raidy-web",
- "version": "0.0.17",
+ "version": "0.0.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "raidy-web",
- "version": "0.0.17",
+ "version": "0.0.18",
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
diff --git a/web/package.json b/web/package.json
index 08a3e12..076c6f8 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,7 +1,7 @@
{
"name": "raidy-web",
"private": true,
- "version": "0.0.17",
+ "version": "0.0.18",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,7 +10,9 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:e2e": "playwright test",
- "build:demo": "tsc -b && vite build --mode demo --base /demo/ --outDir dist-demo"
+ "build:demo": "tsc -b && vite build --mode demo --base /demo/ --outDir dist-demo",
+ "screenshots": "playwright test --config playwright.screenshots.config.ts",
+ "build:pages": "rm -rf dist-pages && tsc -b && vite build --config vite.product.config.ts && vite build --mode demo --base /demo/ --outDir dist-pages/demo"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
index 25f93f6..9d2d093 100644
--- a/web/playwright.config.ts
+++ b/web/playwright.config.ts
@@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
const port = 5179;
const demoPort = 5180;
+const productPort = 5184;
export default defineConfig({
testDir: "e2e",
@@ -15,14 +16,15 @@ export default defineConfig({
launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined }
},
projects: [
- { name: "desktop", testIgnore: "demo.spec.ts", use: { ...devices["Desktop Chrome"] } },
- { name: "mobile", testIgnore: "demo.spec.ts", use: { ...devices["Pixel 7"] } },
+ { name: "desktop", testIgnore: /(demo|product).spec.ts/, use: { ...devices["Desktop Chrome"] } },
+ { name: "mobile", testIgnore: /(demo|product).spec.ts/, use: { ...devices["Pixel 7"] } },
// The demo build runs the real app against in-browser sample data.
{
name: "demo",
testMatch: "demo.spec.ts",
use: { ...devices["Desktop Chrome"], baseURL: `http://127.0.0.1:${demoPort}` }
- }
+ },
+ { name: "product", testMatch: "product.spec.ts", use: { baseURL: `http://127.0.0.1:${productPort}` } }
],
webServer: [
{
@@ -34,6 +36,11 @@ export default defineConfig({
command: `npx vite --mode demo --base /demo/ --host 127.0.0.1 --port ${demoPort} --strictPort`,
url: `http://127.0.0.1:${demoPort}/demo/`,
reuseExistingServer: !process.env.CI
+ },
+ {
+ command: `npx vite --config vite.product.config.ts --host 127.0.0.1 --port ${productPort} --strictPort`,
+ url: `http://127.0.0.1:${productPort}/`,
+ reuseExistingServer: !process.env.CI
}
]
});
diff --git a/web/playwright.screenshots.config.ts b/web/playwright.screenshots.config.ts
new file mode 100644
index 0000000..a38da72
--- /dev/null
+++ b/web/playwright.screenshots.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig } from "@playwright/test";
+
+// Captures the product page's screenshots from the running demo:
+// npm run screenshots
+const demoPort = 5181;
+const productPort = 5182;
+
+export default defineConfig({
+ testDir: "scripts",
+ testMatch: "screenshots.spec.ts",
+ workers: 1,
+ reporter: "list",
+ use: {
+ timezoneId: "Asia/Tokyo",
+ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined }
+ },
+ webServer: [
+ {
+ command: `npx vite --mode demo --base /demo/ --host 127.0.0.1 --port ${demoPort} --strictPort`,
+ url: `http://127.0.0.1:${demoPort}/demo/`,
+ reuseExistingServer: !process.env.CI
+ },
+ {
+ command: `npx vite --config vite.product.config.ts --host 127.0.0.1 --port ${productPort} --strictPort`,
+ url: `http://127.0.0.1:${productPort}/`,
+ reuseExistingServer: !process.env.CI
+ }
+ ]
+});
diff --git a/web/product/.gitignore b/web/product/.gitignore
new file mode 100644
index 0000000..6b09e8d
--- /dev/null
+++ b/web/product/.gitignore
@@ -0,0 +1,2 @@
+public/screens/
+public/og.png
diff --git a/web/product/index.html b/web/product/index.html
new file mode 100644
index 0000000..21654a2
--- /dev/null
+++ b/web/product/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ Raidy: raid nights for Discord statics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/product/main.tsx b/web/product/main.tsx
new file mode 100644
index 0000000..f5d9a4e
--- /dev/null
+++ b/web/product/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { ProductPage } from "@/product/product-page";
+import "./product.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/web/product/product.css b/web/product/product.css
new file mode 100644
index 0000000..c0843ed
--- /dev/null
+++ b/web/product/product.css
@@ -0,0 +1,4 @@
+@import "../src/styles.css";
+
+/* The product page's root is product/, so point Tailwind at the shared sources. */
+@source "../src";
diff --git a/web/product/public/favicon.svg b/web/product/public/favicon.svg
new file mode 100644
index 0000000..d217777
--- /dev/null
+++ b/web/product/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/web/scripts/screenshots.spec.ts b/web/scripts/screenshots.spec.ts
new file mode 100644
index 0000000..6841af6
--- /dev/null
+++ b/web/scripts/screenshots.spec.ts
@@ -0,0 +1,38 @@
+import { devices, expect, test, type Page } from "@playwright/test";
+
+const demo = "http://127.0.0.1:5181/demo/";
+const product = "http://127.0.0.1:5182/";
+const out = "product/public";
+
+async function openDemo(page: Page, theme: "light" | "dark") {
+ await page.emulateMedia({ colorScheme: theme });
+ await page.goto(demo);
+ // The first load can wait on Vite optimizing dependencies.
+ await expect(page.getByRole("table", { name: /availability/i })).toBeVisible({ timeout: 30_000 });
+ // The demo banner is noise on the product page.
+ await page.getByRole("note").evaluate((banner) => banner.remove());
+}
+
+for (const theme of ["light", "dark"] as const) {
+ test(`schedule editor, ${theme}`, async ({ browser }) => {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 });
+ await openDemo(page, theme);
+ await page.getByRole("button", { name: "Schedule" }).click();
+ await expect(page.getByRole("complementary", { name: "Preview" }).getByRole("listitem").first()).toBeVisible();
+ await page.screenshot({ path: `${out}/screens/schedule-${theme}.png` });
+ });
+
+ test(`availability on a phone, ${theme}`, async ({ browser }) => {
+ const page = await browser.newPage({ ...devices["Pixel 7"] });
+ await openDemo(page, theme);
+ await page.screenshot({ path: `${out}/screens/mobile-${theme}.png` });
+ });
+}
+
+test("link preview image", async ({ browser }) => {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto(product);
+ await expect(page.getByRole("figure", { name: "The Raidy post in Discord" })).toBeVisible();
+ await page.screenshot({ path: `${out}/og.png` });
+});
diff --git a/web/src/product/discord-post.tsx b/web/src/product/discord-post.tsx
new file mode 100644
index 0000000..79fd6c3
--- /dev/null
+++ b/web/src/product/discord-post.tsx
@@ -0,0 +1,103 @@
+import { ExternalLink } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export type PostNight = { id: number; startsAt: string; available: number; status: string };
+
+// Mirrors statusIcon and statusLabel in backend/internal/discord/poll.go.
+const icons: Record = {
+ proposed: "🗓️",
+ confirmed: "✅",
+ cancelled: "❌",
+ attention_required: "⚠️"
+};
+const labels: Record = {
+ proposed: "Proposed",
+ confirmed: "Confirmed",
+ cancelled: "Cancelled",
+ attention_required: "Attention required"
+};
+
+/** Discord renders in each reader's own locale and timezone. */
+function discordTime(value: string) {
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(new Date(value));
+}
+
+/**
+ * A faithful copy of the timetable post Raidy keeps in Discord. It is always
+ * drawn in Discord's dark theme, like the real client most people use.
+ */
+export function DiscordPost({
+ team,
+ period,
+ timezone,
+ nights,
+ total,
+ changed
+}: {
+ team: string;
+ period: string;
+ timezone: string;
+ nights: PostNight[];
+ total: number;
+ changed: number | null;
+}) {
+ return (
+
+
+
+ r
+
+
+
+ Raidy
+ APP
+ Today at 9:00
+
+
+
{team} raid timetable
+
+ {period} ·{" "}
+ {timezone}
+
+
+ {nights.map((night) => (
+
+
+ {icons[night.status]} {discordTime(night.startsAt)}
+
+
+
+ {night.available}/{total} available
+ {" "}
+ · {labels[night.status]}
+
+
+ ))}
+
+
+ Times are shown in your timezone. Vote and manage dates in Raidy.
+
+
+
+ Open in Raidy
+
+
+
+
+
+ );
+}
diff --git a/web/src/product/hero-demo.tsx b/web/src/product/hero-demo.tsx
new file mode 100644
index 0000000..cc7c76d
--- /dev/null
+++ b/web/src/product/hero-demo.tsx
@@ -0,0 +1,90 @@
+import { useMemo, useState } from "react";
+import { type Poll } from "@/api";
+import { createState } from "@/demo/data";
+import { addDays } from "@/demo/time";
+import { AvailabilityGrid } from "@/features/overview/availability-grid";
+import { DiscordPost } from "./discord-post";
+
+/**
+ * The grid and the Discord post side by side, both driven by the same sample
+ * week the demo uses. The visitor plays the "You" row; every tap updates the
+ * counts in both places, the way the real app edits the Discord message.
+ */
+export function HeroDemo() {
+ const sample = useMemo(() => createState(), []);
+ const team = sample.teams[0];
+ const week = sample.polls[0];
+ const you = sample.user.id;
+ const [mine, setMine] = useState(
+ () => new Set(week.occurrences.filter((night) => night.votes[you]).map((night) => night.id))
+ );
+ const [changed, setChanged] = useState(null);
+
+ const nights = week.occurrences.map((night) => {
+ const votes = { ...night.votes, [you]: mine.has(night.id) };
+ const available = team.memberIds.filter((id) => votes[id] === true).length;
+ // Same rule as the server: a confirmed night needs everyone.
+ const status =
+ night.status === "confirmed" || night.status === "attention_required"
+ ? available === team.memberIds.length
+ ? "confirmed"
+ : "attention_required"
+ : night.status;
+ return { ...night, votes, available, status };
+ });
+
+ const poll: Poll = {
+ id: week.id,
+ periodStart: week.periodStart,
+ periodEnd: addDays(week.periodStart, week.cadenceDays - 1),
+ timezone: team.timezone,
+ occurrences: nights.map((night) => ({
+ id: night.id,
+ startsAt: night.startsAt,
+ endsAt: night.endsAt,
+ status: night.status,
+ available: night.available,
+ votes: team.memberIds.map((memberId) => ({
+ memberId,
+ status: memberId in night.votes ? (night.votes[memberId] ? "available" : "unavailable") : "pending"
+ }))
+ }))
+ };
+
+ function toggle(id: number) {
+ setMine((current) => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ setChanged(id);
+ window.setTimeout(() => setChanged((value) => (value === id ? null : value)), 900);
+ }
+
+ return (
+
+
+
sample.people.find((person) => person.id === id)!)}
+ canManage={false}
+ busyID={null}
+ onAction={() => {}}
+ currentUserID={you}
+ draft={mine}
+ onToggle={toggle}
+ />
+ You're Alisaie. Tap a night.
+
+
+
+ );
+}
diff --git a/web/src/product/product-page.tsx b/web/src/product/product-page.tsx
new file mode 100644
index 0000000..808818e
--- /dev/null
+++ b/web/src/product/product-page.tsx
@@ -0,0 +1,222 @@
+import { CalendarClock, CalendarSync, Globe, MessageSquareText, MousePointerClick, ShieldCheck } from "lucide-react";
+import { Wordmark } from "@/components/common";
+import { Button } from "@/components/ui/button";
+import { HeroDemo } from "./hero-demo";
+
+const github = "https://github.com/joshchen-dev/raidy";
+
+const features = [
+ {
+ icon: MousePointerClick,
+ title: "One tap per night",
+ text: "Members mark the nights they can make. Any night left blank counts as a no."
+ },
+ {
+ icon: Globe,
+ title: "Everyone's own timezone",
+ text: "Raid times show in each member's local time, with the team's time alongside."
+ },
+ {
+ icon: MessageSquareText,
+ title: "A Discord post that keeps up",
+ text: "One message per week, edited in place as votes come in. No channel spam."
+ },
+ {
+ icon: ShieldCheck,
+ title: "The leader makes the call",
+ text: "Confirm or cancel a night. A confirmed night is flagged if someone drops out."
+ },
+ {
+ icon: CalendarSync,
+ title: "Move the raid, keep the votes",
+ text: "Change the start time and the open nights move with it."
+ },
+ {
+ icon: CalendarClock,
+ title: "Runs on schedule",
+ text: "Weekly or every two weeks, posted a few days ahead without anyone remembering."
+ }
+];
+
+const steps = [
+ { title: "Create your team", text: "Sign in with Discord and pick up to eight members from your server." },
+ { title: "Set the schedule", text: "Choose raid days, a time and a channel." },
+ { title: "Vote and lock it in", text: "Members tap their nights. The leader confirms." }
+];
+
+const questions = [
+ {
+ q: "Is it free?",
+ a: "Yes. Raidy is open source under the MIT license, and you run it yourself with Docker or Kubernetes."
+ },
+ {
+ q: "What can the bot do in my server?",
+ a: "Only what it needs to post: view channels, send messages, embed links and read message history. Members vote on the web, not in Discord."
+ },
+ {
+ q: "How do timezones work?",
+ a: "The leader sets the team's timezone once. Everyone else sees raid times converted to their own, on the web and in Discord."
+ },
+ {
+ q: "Is it only for Final Fantasy XIV?",
+ a: "It was built for FFXIV statics, but any group that meets on fixed nights can use it."
+ }
+];
+
+export function ProductPage() {
+ return (
+
+
+
+
+
+
+ See which nights your whole static can raid.
+
+
+ Your raid team votes on the web. Raidy keeps one post in your Discord server up to date.
+
+
+
+
+
+
+
+
+
+
+ Less chasing replies, more raiding.
+
+
+ {features.map(({ icon: Icon, title, text }) => (
+
+
+ {title}
+ {text}
+
+ ))}
+
+
+
+
+
+
+
Set up in three steps
+
+ {steps.map((step, index) => (
+
+ {index + 1}
+ {step.title}
+ {step.text}
+
+ ))}
+
+
+
+
+
+
+
+ Inside the app
+
+
+
+
+
+
+
+
+
+
+
+
Free and open source
+
+ Run Raidy on your own server with Docker or Kubernetes. Built with Go, PostgreSQL and React.
+
+
+
+
+
+
+
+
+
Questions
+
+ {questions.map(({ q, a }) => (
+
+
+ {q}
+
+ +
+
+
+ {a}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+/** A screenshot of the real app, in the viewer's color scheme. */
+function Screen({ name, alt, narrow = false }: { name: string; alt: string; narrow?: boolean }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
index 9edfec6..25a5c19 100644
--- a/web/tsconfig.app.json
+++ b/web/tsconfig.app.json
@@ -21,5 +21,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
- "include": ["src"]
+ "include": ["src", "product/main.tsx"]
}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
index 3950b4b..fc1be6e 100644
--- a/web/tsconfig.node.json
+++ b/web/tsconfig.node.json
@@ -12,5 +12,5 @@
"noEmit": true,
"strict": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "vite.product.config.ts", "playwright.screenshots.config.ts"]
}
diff --git a/web/vite.product.config.ts b/web/vite.product.config.ts
new file mode 100644
index 0000000..b139d89
--- /dev/null
+++ b/web/vite.product.config.ts
@@ -0,0 +1,19 @@
+import path from "node:path";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+// The product page at the site root; the demo build is added under /demo/.
+export default defineConfig({
+ root: path.resolve(__dirname, "product"),
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src")
+ }
+ },
+ build: {
+ outDir: path.resolve(__dirname, "dist-pages"),
+ emptyOutDir: false
+ }
+});