From 43e80aef66fd3c3a14b8fb6d2e5c0bb811d7a505 Mon Sep 17 00:00:00 2001 From: Beckett Frey Date: Mon, 24 Aug 2026 16:17:28 -0500 Subject: [PATCH 1/4] Turn the download page into a stepped installation flow /download becomes /installation, and the single download panel becomes a four-step sequence: check your machine, download a build, get set up, and an optional feedback step. The steps are a wizard rather than a stacked page. Only the active step is expanded; the rest collapse to a titled row. Navigation lives entirely on the connectors between the cards -- the pulsing chevron below the open step goes forward, the quieter one above it goes back -- so the rail is both the ordering signal and the only way to move. Collapsed headings are inert on purpose: two competing navigation affordances is how a visitor loses track of which step they are on. Collapsed bodies stay mounted behind `hidden` rather than being unmounted, so the server-rendered version numbers are still in the HTML for crawlers and no-JS readers even while the download step sits behind the gate. The OS selection is lifted into InstallFlowClient because it spans two cards: the build you take and the instructions for installing it. Step titles are named once in INSTALL_STEPS, since each is shown both as a panel heading and as the destination its connector announces, and the two drifted apart the first time they were separate strings. The feedback form composes a mailto rather than posting anywhere -- this site has no backend to receive form posts, and a form that discarded what someone wrote would be worse than no form. /download permanently redirects to /installation, so existing links and anything indexed keep working. --- .github/CONTRIBUTING.md | 8 +- app/api/revalidate-releases/route.ts | 4 +- app/features/page.tsx | 2 +- app/globals.css | 51 +++++ app/{download => installation}/page.tsx | 29 ++- app/page.tsx | 2 +- components/DownloadPanel.tsx | 260 +++++++++++++++++++++-- components/DownloadPanelClient.tsx | 268 ------------------------ components/FeedbackPanel.tsx | 199 ++++++++++++++++++ components/GettingStartedPanel.tsx | 109 ++++++++++ components/InstallFlow.tsx | 29 +++ components/InstallFlowClient.tsx | 139 ++++++++++++ components/RequirementsPanel.tsx | 50 +++++ components/StepConnector.tsx | 115 ++++++++++ components/StepPanel.tsx | 110 ++++++++++ data/help-content.json | 2 +- data/install-content.ts | 138 ++++++++++++ layout/Footer.tsx | 2 +- layout/Navbar.tsx | 2 +- lib/os.ts | 33 +++ next.config.ts | 13 +- 21 files changed, 1250 insertions(+), 315 deletions(-) rename app/{download => installation}/page.tsx (52%) delete mode 100644 components/DownloadPanelClient.tsx create mode 100644 components/FeedbackPanel.tsx create mode 100644 components/GettingStartedPanel.tsx create mode 100644 components/InstallFlow.tsx create mode 100644 components/InstallFlowClient.tsx create mode 100644 components/RequirementsPanel.tsx create mode 100644 components/StepConnector.tsx create mode 100644 components/StepPanel.tsx create mode 100644 data/install-content.ts create mode 100644 lib/os.ts diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 18c7d78..0af72ce 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -96,7 +96,9 @@ Also in `Settings → General → Pull Requests`: ### Release data freshness -`/download` is **prerendered**, not fetched in the browser. `components/DownloadPanel.tsx` is a server component that resolves GitHub releases at render time via `lib/releases.ts`, then hands the data to `DownloadPanelClient.tsx` for the interactive bits. Visitors never call GitHub, so the page costs one upstream request per day instead of one per visitor — comfortably under GitHub's 60/hour unauthenticated limit. +`/installation` is **prerendered**, not fetched in the browser. `components/InstallFlow.tsx` is a server component that resolves GitHub releases at render time via `lib/releases.ts`, then hands the data to `InstallFlowClient.tsx` for the interactive bits. Visitors never call GitHub, so the page costs one upstream request per day instead of one per visitor — comfortably under GitHub's 60/hour unauthenticated limit. + +(`/download` is the page's former URL and permanently redirects to `/installation`; the redirect is declared in `next.config.ts`.) Refresh is driven by the cron in `vercel.json`: @@ -104,11 +106,11 @@ Refresh is driven by the cron in `vercel.json`: 0 0 * * * → /api/revalidate-releases ``` -At midnight UTC it purges the `releases` cache tag and the prerendered `/download` page; the next visitor triggers one fresh fetch. **A new release therefore takes up to a day to appear on the site.** That delay is intentional — a grace period to pull a release that turns out to be problematic before the website advertises it. +At midnight UTC it purges the `releases` cache tag and the prerendered `/installation` page; the next visitor triggers one fresh fetch. **A new release therefore takes up to a day to appear on the site.** That delay is intentional — a grace period to pull a release that turns out to be problematic before the website advertises it. Two safety nets: -- `export const revalidate` on `app/download/page.tsx` (24h) refreshes the page even if the cron stops firing. It duplicates `RELEASES_REVALIDATE_SECONDS` in `lib/releases.ts` because Next requires a literal there — change both together. +- `export const revalidate` on `app/installation/page.tsx` (24h) refreshes the page even if the cron stops firing. It duplicates `RELEASES_REVALIDATE_SECONDS` in `lib/releases.ts` because Next requires a literal there — change both together. - If GitHub is down when a refresh runs, the last good render keeps being served; visitors see nothing wrong. To publish a release immediately, either redeploy or call the endpoint by hand: diff --git a/app/api/revalidate-releases/route.ts b/app/api/revalidate-releases/route.ts index 9ef73f8..a2747bc 100644 --- a/app/api/revalidate-releases/route.ts +++ b/app/api/revalidate-releases/route.ts @@ -5,7 +5,7 @@ import { RELEASES_CACHE_TAG } from "../../../lib/releases"; /** * Cron target, wired to midnight UTC daily in `vercel.json`. * - * Drops both the cached GitHub response and the prerendered download page, so + * Drops both the cached GitHub response and the prerendered installation page, so * the first visitor after midnight triggers one fresh fetch. New releases are * therefore picked up with up to a day's delay — a deliberate grace period in * case a release turns out to be problematic. @@ -28,7 +28,7 @@ export async function GET(request: Request) { } revalidateTag(RELEASES_CACHE_TAG, "max"); - revalidatePath("/download"); + revalidatePath("/installation"); return NextResponse.json({ revalidated: true, diff --git a/app/features/page.tsx b/app/features/page.tsx index 5d5bda5..879ca50 100644 --- a/app/features/page.tsx +++ b/app/features/page.tsx @@ -352,7 +352,7 @@ export default function FeaturesPage() { Research Foundations Download VoxKit diff --git a/app/globals.css b/app/globals.css index 3991ca1..0eb5f69 100644 --- a/app/globals.css +++ b/app/globals.css @@ -70,6 +70,57 @@ body:has([data-full-viewport]) { .fall-in { animation: none !important; transform: none !important; } } */ +/* + * The chevron on an installation-step connector, gliding down its rail to mark + * where the flow continues. Loops indefinitely because it is the page's only + * "do this next" signal once the step numbers came out of the panel corners. + */ +@keyframes stepChevronGlide { + 0%, + 100% { + transform: translateY(-3px); + opacity: 0.5; + } + 50% { + transform: translateY(3px); + opacity: 1; + } +} +.step-chevron-glide { + animation: stepChevronGlide 1.6s ease-in-out infinite; +} + +/* + * A halo expanding out of the live connector button. The rail around it is + * deliberately quiet, so this is what says "press this" from across the page. + * cyan-400 (#22d3ee) to match the ring the button already carries. + */ +@keyframes stepAdvancePulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.4); + } + 50% { + box-shadow: 0 0 0 10px rgba(34, 211, 238, 0); + } +} +.step-advance-pulse { + animation: stepAdvancePulse 2.4s ease-out infinite; +} + +/* Indefinite loops are exactly what this preference is for. The button keeps + its ring, fill, and label, so it is no less obviously a control without the + motion; only the attention-grabbing halo and glide stop. */ +@media (prefers-reduced-motion: reduce) { + .step-chevron-glide { + animation: none; + opacity: 1; + } + .step-advance-pulse { + animation: none; + } +} + @keyframes gradient-shift { 0%, 100% { diff --git a/app/download/page.tsx b/app/installation/page.tsx similarity index 52% rename from app/download/page.tsx rename to app/installation/page.tsx index 8897e14..9a97ef2 100644 --- a/app/download/page.tsx +++ b/app/installation/page.tsx @@ -1,12 +1,11 @@ import type { Metadata } from "next"; -import { Info } from "lucide-react"; import { Footer, Navbar } from "../../layout"; -import DownloadPanel from "../../components/DownloadPanel"; +import InstallFlow from "../../components/InstallFlow"; export const metadata: Metadata = { - title: "Download VoxKit", + title: "Installation Guide", description: - "Download the latest VoxKit release for macOS, Windows, or Linux. Built for speech pathology researchers, no command line required.", + "Download and setup the latest version of VoxKit for macOS, Windows, or Linux. Built for speech pathology researchers, no command line required.", }; // Fallback only: the nightly cron at /api/revalidate-releases is what normally @@ -14,28 +13,26 @@ export const metadata: Metadata = { // Next requires a literal here, so it cannot be imported. export const revalidate = 86400; -export default function DownloadPage() { +export default function InstallationPage() { return (
- +

- Download VoxKit + Installation Guide

- Get the latest version of VoxKit for your operating system -

-
-
- -

- Some researchers with university-managed devices may need to contact - their university's IT to install the app. + Download and setup the latest version of the app

- + {/* The one column the three steps share. Width lives here so the + panels stay aligned with each other rather than each choosing; + the connectors between them supply the vertical rhythm. */} +
+ +
diff --git a/app/page.tsx b/app/page.tsx index 3cdf486..6e0b834 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -205,7 +205,7 @@ export default function VoxKitLanding() { Getting Started
Download Now diff --git a/components/DownloadPanel.tsx b/components/DownloadPanel.tsx index 6d837d0..ffac63b 100644 --- a/components/DownloadPanel.tsx +++ b/components/DownloadPanel.tsx @@ -1,29 +1,249 @@ -import { getReleases } from "../lib/releases"; -import DownloadPanelClient from "./DownloadPanelClient"; +"use client"; +import { Download, AlertTriangle } from "lucide-react"; +import { AppleIcon, WindowsIcon, LinuxIcon } from "./OSIcons"; +import { PRE_RELEASE_DISCLAIMER } from "../lib/fakeReleases"; +import { INSTALL_STEPS } from "../data/install-content"; +import GridButton from "./GridButton"; +import StepPanel, { type StepState } from "./StepPanel"; +import { getOSDisplayName, type OS } from "../lib/os"; +import type { + ReleaseAsset, + GroupedReleases, + OperatingSystem, + LatestRelease, +} from "../types/releases"; + +type DownloadPanelProps = StepState & { + releases: GroupedReleases; + /** ISO timestamp of the server render that produced `releases`, if it succeeded. */ + lastUpdated: string | null; + /** Why the release lookup failed, when it did. Replaces the release card. */ + error: string | null; + /** OS whose release the panel is showing. */ + selectedOS: OS; + /** OS sniffed from the user agent, badged as "Detected" on its tile. */ + detectedOS: OS; + onSelectOS: (os: OperatingSystem) => void; + /** Taking a build completes this step, so the flow can move the visitor on. */ + onDownloaded: () => void; +}; + +/** Left-to-right order of the OS tiles. */ +const OS_DISPLAY_ORDER = ["macos", "windows", "linux"] as const; + +// Dates are formatted in a fixed locale and timezone so the server render and +// the hydrated client render agree. `toLocaleDateString()` would resolve +// against the visitor's timezone and mismatch the prerendered HTML. +const RELEASE_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "numeric", +}); + +const LAST_UPDATED_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + dateStyle: "medium", + timeStyle: "short", +}); + +const isPreReleaseVersion = (version: string): boolean => { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return false; + const major = parseInt(match[1], 10); + return major < 1; +}; /** - * Server component. Resolves release data during render so the panel arrives - * fully populated in the initial HTML: no client fetch, no loading flash, and - * the version numbers are visible to crawlers. + * Second step: pick an operating system and take the build for it. * - * Freshness is controlled by the page's `revalidate` plus the nightly cron in - * `app/api/revalidate-releases/route.ts`, not by this component. + * Controlled rather than self-contained. The OS selection also drives the + * install steps in the step below, so it is owned by `InstallFlowClient`. */ -export default async function DownloadPanel() { - const result = await getReleases(); +export default function DownloadPanel({ + releases, + lastUpdated, + error, + selectedOS, + detectedOS, + onSelectOS, + onDownloaded, + ...stepState +}: DownloadPanelProps) { + const selectBestAsset = ( + assets: ReleaseAsset[], + os: OS, + ): ReleaseAsset | null => { + if (!assets || assets.length === 0) return null; + if (assets.length === 1) return assets[0]; - if (!result.ok) { - return ( -
-
{result.error}
-
- ); - } + const priorities: Record = { + macos: [/\.dmg$/i, /\.pkg$/i], + windows: [/\.exe$/i, /\.msi$/i], + linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i], + }; + + if (!os || !priorities[os]) return assets[0]; + + for (const pattern of priorities[os]) { + const match = assets.find((asset) => pattern.test(asset.name)); + if (match) return match; + } + + return assets[0]; + }; + + // Taking the build is the completion signal for this step. Advancing here as + // well as on the connector below means the common path -- click Download, + // look up, wonder what now -- lands on the install instructions unprompted. + const handleDownload = (asset: ReleaseAsset) => { + if (asset?.url) { + window.open(asset.url, "_blank"); + onDownloaded(); + } + }; + + const formatFileSize = (bytes: number) => { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(1)} MB`; + }; + + const currentRelease: LatestRelease | undefined = selectedOS + ? releases[selectedOS] + : undefined; + const availableOSes = Object.keys(releases); + const hasPreReleaseVersion = + !!currentRelease && + (currentRelease.prerelease || isPreReleaseVersion(currentRelease.version)); + const bestAsset = currentRelease + ? selectBestAsset(currentRelease.assets, selectedOS) + : null; return ( - + + {/* OS Selection */} +
+ {OS_DISPLAY_ORDER.map((os) => { + const isSelected = selectedOS === os; + const isDetected = detectedOS === os; + const isAvailable = availableOSes.includes(os); + + return ( + onSelectOS(os)} + disabled={!isAvailable} + aria-pressed={isSelected} + className={` + px-6 py-4 text-sm font-medium transition-all + ${isAvailable ? "" : "opacity-50 cursor-not-allowed"} + ${ + isSelected + ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-400/50" + : "bg-slate-700/50 text-slate-300 border border-slate-600" + } + `} + > +
+
+ {os === "windows" && } + {os === "macos" && } + {os === "linux" && } +
+
{getOSDisplayName(os)}
+ {isDetected && ( +
Detected
+ )} + {!isAvailable && ( +
Not Available
+ )} +
+
+ ); + })} +
+ + {/* Separates picking an OS from the release it resolves to, so the + caveat lands on the build about to be downloaded. */} + {hasPreReleaseVersion && ( +
+
+ )} + + {/* Release Download */} + {currentRelease ? ( +
+
+
+

+ {getOSDisplayName(selectedOS)} • v{currentRelease.version} +

+

+ Released{" "} + {RELEASE_DATE_FORMAT.format( + new Date(currentRelease.publishedAt), + )} +

+ {bestAsset && ( +

+ {bestAsset.name} • {formatFileSize(bestAsset.size)} +

+ )} +
+
+ {bestAsset ? ( + handleDownload(bestAsset)} + className="text-small px-8 py-4 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white border-white" + rippleColor="rgba(255, 255, 255, 0.5)" + > + + ) : ( + + No assets + + )} + window.open(currentRelease.htmlUrl, "_blank")} + className="text-small px-8 py-4 rounded-lg text-cyan-400 border-cyan-400" + > + View Release + +
+
+
+ ) : ( +
+ {error ?? + `No release available for ${getOSDisplayName(selectedOS) || "this OS"}.`} +
+ )} + + {/* Footer */} +
+ {lastUpdated && + `Last updated ${LAST_UPDATED_FORMAT.format(new Date(lastUpdated))} UTC. `} + + View all releases on GitHub + + . +
+
); } diff --git a/components/DownloadPanelClient.tsx b/components/DownloadPanelClient.tsx deleted file mode 100644 index 066d2c4..0000000 --- a/components/DownloadPanelClient.tsx +++ /dev/null @@ -1,268 +0,0 @@ -"use client"; -import { useState, useSyncExternalStore } from "react"; -import { Download, AlertTriangle } from "lucide-react"; -import { AppleIcon, WindowsIcon, LinuxIcon } from "./OSIcons"; -import { PRE_RELEASE_DISCLAIMER } from "../lib/fakeReleases"; -import GridButton from "./GridButton"; -import type { - ReleaseAsset, - GroupedReleases, - OperatingSystem, - LatestRelease, -} from "../types/releases"; - -type OS = OperatingSystem | null; - -type DownloadPanelClientProps = { - releases: GroupedReleases; - /** ISO timestamp of the server render that produced `releases`. */ - lastUpdated: string; -}; - -/** Left-to-right order of the OS tiles. */ -const OS_DISPLAY_ORDER = ["macos", "windows", "linux"] as const; - -/** - * Which OS the panel falls back to when detection fails or the visitor's OS - * has no build. Deliberately independent of the tile order above, so moving a - * tile does not change what the prerendered HTML shows. - */ -const OS_FALLBACK_ORDER = ["windows", "macos", "linux"] as const; - -// Dates are formatted in a fixed locale and timezone so the server render and -// the hydrated client render agree. `toLocaleDateString()` would resolve -// against the visitor's timezone and mismatch the prerendered HTML. -const RELEASE_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { - timeZone: "UTC", - year: "numeric", - month: "short", - day: "numeric", -}); - -const LAST_UPDATED_FORMAT = new Intl.DateTimeFormat("en-US", { - timeZone: "UTC", - dateStyle: "medium", - timeStyle: "short", -}); - -const detectOS = (): OS => { - if (typeof window === "undefined") return null; - const userAgent = window.navigator.userAgent.toLowerCase(); - if (userAgent.includes("win")) return "windows"; - if (userAgent.includes("mac")) return "macos"; - if (userAgent.includes("linux")) return "linux"; - return null; -}; - -// There is no user agent to sniff during SSR, so detection is modeled as an -// external store: React renders `null` on the server and again while -// hydrating, then re-renders with the real value. The user agent never changes -// mid-session, so there is nothing to subscribe to. -const subscribeToOS = () => () => {}; -const getServerOS = (): OS => null; - -const isPreReleaseVersion = (version: string): boolean => { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); - if (!match) return false; - const major = parseInt(match[1], 10); - return major < 1; -}; - -export default function DownloadPanelClient({ - releases, - lastUpdated, -}: DownloadPanelClientProps) { - const detectedOS = useSyncExternalStore(subscribeToOS, detectOS, getServerOS); - const [pickedOS, setPickedOS] = useState(null); - - // Derived rather than stored, so the server and the hydrated client agree: - // an explicit pick wins, then the detected OS if we ship builds for it, then - // the first OS with a release at all. - const selectedOS: OS = - pickedOS ?? - (detectedOS && releases[detectedOS] - ? detectedOS - : (OS_FALLBACK_ORDER.find((os) => releases[os]) ?? null)); - - const getReleaseForOS = (): LatestRelease | undefined => { - if (!selectedOS) return undefined; - return releases[selectedOS]; - }; - - const selectBestAsset = ( - assets: ReleaseAsset[], - os: OS, - ): ReleaseAsset | null => { - if (!assets || assets.length === 0) return null; - if (assets.length === 1) return assets[0]; - - const priorities: Record = { - macos: [/\.dmg$/i, /\.pkg$/i], - windows: [/\.exe$/i, /\.msi$/i], - linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i], - }; - - if (!os || !priorities[os]) return assets[0]; - - for (const pattern of priorities[os]) { - const match = assets.find((asset) => pattern.test(asset.name)); - if (match) return match; - } - - return assets[0]; - }; - - const handleDownload = (asset: ReleaseAsset) => { - if (asset?.url) { - window.open(asset.url, "_blank"); - } - }; - - const formatFileSize = (bytes: number) => { - const mb = bytes / (1024 * 1024); - return `${mb.toFixed(1)} MB`; - }; - - const getOSDisplayName = (os: OS) => { - if (!os) return ""; - return os === "macos" ? "macOS" : os === "windows" ? "Windows" : "Linux"; - }; - - const currentRelease = getReleaseForOS(); - const availableOSes = Object.keys(releases); - const hasPreReleaseVersion = - !!currentRelease && - (currentRelease.prerelease || isPreReleaseVersion(currentRelease.version)); - const bestAsset = currentRelease - ? selectBestAsset(currentRelease.assets, selectedOS) - : null; - - return ( -
-

- - Download VoxKit -

-

- Select your operating system -

- - {/* OS Selection */} -
- {OS_DISPLAY_ORDER.map((os) => { - const isSelected = selectedOS === os; - const isDetected = detectedOS === os; - const isAvailable = availableOSes.includes(os); - - return ( - setPickedOS(os)} - disabled={!isAvailable} - className={` - px-6 py-4 text-sm font-medium transition-all - ${isAvailable ? "" : "opacity-50 cursor-not-allowed"} - ${ - isSelected - ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-400/50" - : "bg-slate-700/50 text-slate-300 border border-slate-600" - } - `} - > -
-
- {os === "windows" && } - {os === "macos" && } - {os === "linux" && } -
-
{getOSDisplayName(os)}
- {isDetected && ( -
Detected
- )} - {!isAvailable && ( -
Not Available
- )} -
-
- ); - })} -
- - {/* Separates picking an OS from the release it resolves to, so the - caveat lands on the build about to be downloaded. */} - {hasPreReleaseVersion ? ( -
- -

{PRE_RELEASE_DISCLAIMER}

-
- ) : ( -
- )} - - {/* Release Download */} - {currentRelease ? ( -
-
-
-
- {getOSDisplayName(selectedOS)} • v{currentRelease.version} -
-

- Released{" "} - {RELEASE_DATE_FORMAT.format( - new Date(currentRelease.publishedAt), - )} -

- {bestAsset && ( -

- {bestAsset.name} • {formatFileSize(bestAsset.size)} -

- )} -
-
- {bestAsset ? ( - handleDownload(bestAsset)} - className="text-small px-8 py-4 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white border-white" - rippleColor="rgba(255, 255, 255, 0.5)" - > - - Download - - ) : ( - - No assets - - )} - window.open(currentRelease.htmlUrl, "_blank")} - className="text-small px-8 py-4 rounded-lg text-cyan-400 border-cyan-400" - > - View Release - -
-
-
- ) : ( -
- No release available for {getOSDisplayName(selectedOS) || "this OS"}. -
- )} - - {/* Footer */} -
-

- Last updated: {LAST_UPDATED_FORMAT.format(new Date(lastUpdated))} UTC -

- - View all releases on GitHub - -
-
- ); -} diff --git a/components/FeedbackPanel.tsx b/components/FeedbackPanel.tsx new file mode 100644 index 0000000..88bedae --- /dev/null +++ b/components/FeedbackPanel.tsx @@ -0,0 +1,199 @@ +"use client"; +import { useState } from "react"; +import { Check, Mail } from "lucide-react"; +import StepPanel, { type StepState } from "./StepPanel"; +import GridButton from "./GridButton"; +import { + FEEDBACK_SCALE, + FEEDBACK_SCALE_HIGH_LABEL, + FEEDBACK_SCALE_LOW_LABEL, + FEEDBACK_SUBJECT, + INSTALL_STEPS, + SUPPORT_EMAIL, +} from "../data/install-content"; +import { getOSDisplayName, type OS } from "../lib/os"; + +type FeedbackPanelProps = StepState & { + /** OS the flow has been describing, reported alongside the answers. */ + os: OS; + /** Version the visitor was offered, if the release lookup produced one. */ + version: string | null; +}; + +const RATINGS = Array.from( + { length: FEEDBACK_SCALE.max - FEEDBACK_SCALE.min + 1 }, + (_, index) => FEEDBACK_SCALE.min + index, +); + +/** + * Optional last step: how the install actually felt. + * + * Submitting opens the visitor's mail client with the answers composed into a + * message to support, rather than posting anywhere. This site has no backend to + * receive form posts -- the only route is the revalidation cron -- and a form + * that silently discarded what someone took the time to write would be worse + * than no form. The button says "Open email draft" for the same reason: the + * mail client appearing should be the expected outcome, not a surprise. + * + * Swapping this for a real endpoint is a change to `handleSubmit` alone; the + * fields and their wording stay as they are. + * + * The platform and version are attached automatically. They are the first + * things anyone reading the report would have to ask for, and the flow already + * knows both. + */ +export default function FeedbackPanel({ + os, + version, + ...stepState +}: FeedbackPanelProps) { + const [rating, setRating] = useState(null); + const [issues, setIssues] = useState(""); + const [sent, setSent] = useState(false); + + // An empty report helps nobody, but either half on its own is worth having. + const hasAnswer = rating !== null || issues.trim().length > 0; + + const draftUrl = () => { + const body = [ + `How intuitive was the installation? ${ + rating === null + ? "(not answered)" + : `${rating} out of ${FEEDBACK_SCALE.max}` + }`, + "", + "Issues encountered:", + issues.trim() || "(none reported)", + "", + "---", + `Platform: ${getOSDisplayName(os) || "unknown"}`, + `Version offered: ${version ? `v${version}` : "unknown"}`, + ].join("\n"); + + return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent( + FEEDBACK_SUBJECT, + )}&body=${encodeURIComponent(body)}`; + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!hasAnswer) return; + window.location.href = draftUrl(); + setSent(true); + }; + + if (sent) { + return ( + +
+
+
+ ); + } + + return ( + +
+
+ + How intuitive was the installation? + +
+ {RATINGS.map((value) => { + const isPicked = rating === value; + return ( + + ); + })} +
+
+ {FEEDBACK_SCALE_LOW_LABEL} + {FEEDBACK_SCALE_HIGH_LABEL} +
+
+ +
+ +