From 49efe5880c2aa9569128598129b38658cbce66aa Mon Sep 17 00:00:00 2001 From: joshchen-dev Date: Sat, 24 Oct 2026 02:59:24 +0000 Subject: [PATCH] feat: product page with a live demo on GitHub Pages A public page for Raidy, deployed with GitHub Pages. - The hero is the product: the real availability grid next to a copy of the Discord post. The visitor plays the "You" row, and every tap updates the counts and status in both, as the real app does. - Features, the three setup steps, screenshots of the real app (light and dark, captured from the demo at deploy time), open source and FAQ. - "Try the live demo" opens the demo build at /demo/. - `npm run build:pages` builds the page and the demo into dist-pages; the Pages workflow captures screenshots, builds and deploys on every merge to main. CI also checks that the site builds. Release v0.0.18. --- .github/workflows/ci.yml | 3 + .github/workflows/pages.yml | 47 ++++++ .gitignore | 1 + README.md | 6 +- web/.prettierignore | 1 + web/e2e/product.spec.ts | 31 ++++ web/package-lock.json | 4 +- web/package.json | 6 +- web/playwright.config.ts | 13 +- web/playwright.screenshots.config.ts | 29 ++++ web/product/.gitignore | 2 + web/product/index.html | 30 ++++ web/product/main.tsx | 10 ++ web/product/product.css | 4 + web/product/public/favicon.svg | 1 + web/scripts/screenshots.spec.ts | 38 +++++ web/src/product/discord-post.tsx | 103 +++++++++++++ web/src/product/hero-demo.tsx | 90 +++++++++++ web/src/product/product-page.tsx | 222 +++++++++++++++++++++++++++ web/tsconfig.app.json | 2 +- web/tsconfig.node.json | 2 +- web/vite.product.config.ts | 19 +++ 22 files changed, 654 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 web/e2e/product.spec.ts create mode 100644 web/playwright.screenshots.config.ts create mode 100644 web/product/.gitignore create mode 100644 web/product/index.html create mode 100644 web/product/main.tsx create mode 100644 web/product/product.css create mode 100644 web/product/public/favicon.svg create mode 100644 web/scripts/screenshots.spec.ts create mode 100644 web/src/product/discord-post.tsx create mode 100644 web/src/product/hero-demo.tsx create mode 100644 web/src/product/product-page.tsx create mode 100644 web/vite.product.config.ts 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 ( +
+
+ +
+
+ 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 }) => ( +
  • +
  • + ))} +
+
+
+ +
+
+

Set up in three steps

+
    + {steps.map((step, index) => ( +
  1. + {index + 1} +

    {step.title}

    +

    {step.text}

    +
  2. + ))} +
+
+
+ +
+
+

+ 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 ( + + + {alt} + + ); +} 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 + } +});