diff --git a/src/lib/download-asset.test.ts b/src/lib/download-asset.test.ts new file mode 100644 index 0000000..1da2618 --- /dev/null +++ b/src/lib/download-asset.test.ts @@ -0,0 +1,88 @@ +/** + * Unit tests for the shared `downloadAsset` helper (task 05). + * + * The helper is a pure function over an injected `DownloadApi` + redirect sink, + * so these tests verify the tRPC call shape, redirect, and error handling + * without importing `~/lib/api` (which pulls in solid-js / CSRF cookie code). + */ +import { describe, it, expect, mock } from "bun:test"; +import { downloadAsset, type DownloadApi } from "~/lib/download-asset"; + +function makeFakeApi( + url: string, + seen: { input: { asset_name: string } }[] = [] +): DownloadApi { + return { + downloads: { + getDownloadUrl: { + query: async (input: { asset_name: string }) => { + seen.push({ input }); + return { downloadURL: url }; + } + } + } + }; +} + +describe("downloadAsset", () => { + it("queries getDownloadUrl with the provided asset_name", async () => { + const seen: { input: { asset_name: string } }[] = []; + const api = makeFakeApi("https://s3/gaze.dmg", seen); + await downloadAsset({ api, assetName: "gaze", redirect: () => {} }); + expect(seen).toHaveLength(1); + expect(seen[0]!.input.asset_name).toBe("gaze"); + }); + + it("redirects to the returned signed URL", async () => { + const api = makeFakeApi("https://s3/gaze.dmg"); + const sink = mock((u: string) => {}); + await downloadAsset({ api, assetName: "gaze", redirect: sink }); + expect(sink).toHaveBeenCalledTimes(1); + expect(sink).toHaveBeenCalledWith("https://s3/gaze.dmg"); + }); + + it("routes failures to onError without throwing", async () => { + const api: DownloadApi = { + downloads: { + getDownloadUrl: { + query: async () => { + throw new Error("boom"); + } + } + } + }; + const sink = mock((u: string) => {}); + const errSink = mock((e: unknown) => {}); + await expect( + downloadAsset({ api, assetName: "gaze", redirect: sink, onError: errSink }) + ).resolves.toBeUndefined(); + expect(sink).not.toHaveBeenCalled(); + expect(errSink).toHaveBeenCalledTimes(1); + }); + + it("swallows errors silently when no onError is provided", async () => { + const api: DownloadApi = { + downloads: { + getDownloadUrl: { + query: async () => { + throw new Error("boom"); + } + } + } + }; + const sink = mock((u: string) => {}); + await expect( + downloadAsset({ api, assetName: "gaze", redirect: sink }) + ).resolves.toBeUndefined(); + expect(sink).not.toHaveBeenCalled(); + }); + + it("works for any asset name (not hard-coded to gaze)", async () => { + const seen: { input: { asset_name: string } }[] = []; + const api = makeFakeApi("https://s3/inputhalo.dmg", seen); + const sink = mock((u: string) => {}); + await downloadAsset({ api, assetName: "inputhalo", redirect: sink }); + expect(seen[0]!.input.asset_name).toBe("inputhalo"); + expect(sink).toHaveBeenCalledWith("https://s3/inputhalo.dmg"); + }); +}); diff --git a/src/lib/download-asset.ts b/src/lib/download-asset.ts new file mode 100644 index 0000000..67a0d9c --- /dev/null +++ b/src/lib/download-asset.ts @@ -0,0 +1,76 @@ +/** + * Pure, testable helper for triggering a signed-S3 download via the tRPC + * `downloads.getDownloadUrl` endpoint. + * + * Extracted (task 05) so the Gaze landing page's download button — and any + * other subdomain landing page that needs the same flow (InputHalo, Lineage, + * …) — can share a single code path AND be unit-tested without importing + * `~/lib/api` (which transitively imports solid-js / CSRF cookie access). + * + * The component layer supplies the concrete `api` (dynamic-imported at click + * time) and the `redirect` sink (defaults to `window.location.href = url`). + * Tests inject a fake `api` and a capture sink. + */ + +/** Structural shape of the tRPC downloads proxy this helper depends on. */ +export interface DownloadApi { + downloads: { + getDownloadUrl: { + query: (input: { asset_name: string }) => Promise<{ + downloadURL: string; + }>; + }; + }; +} + +/** A `(url) => void` sink the helper calls with the signed S3 URL. */ +export type DownloadRedirect = (url: string) => void; + +export interface DownloadAssetOptions { + /** tRPC proxy (or fake) exposing `downloads.getDownloadUrl.query`. */ + api: DownloadApi; + /** Asset key known to the downloads router (e.g. `"gaze"`). */ + assetName: string; + /** Called with the signed URL. Defaults to `window.location.href = url`. */ + redirect?: DownloadRedirect; + /** Invoked on failure; defaults to a no-op (component shows its own UI). */ + onError?: (error: unknown) => void; +} + +/** Default redirect sink — browser navigation to the signed S3 URL. */ +const defaultRedirect: DownloadRedirect = (url) => { + if (typeof window !== "undefined") { + window.location.href = url; + } +}; + +/** + * Resolve the latest signed download URL for `assetName` and redirect the + * browser to it. Never throws — failures are routed to `onError`. + * + * @example + * ```ts + * import("~/lib/api").then(({ api }) => { + * downloadAsset({ api, assetName: "gaze" }); + * }); + * ``` + */ +export async function downloadAsset( + options: DownloadAssetOptions +): Promise { + const { + api, + assetName, + redirect = defaultRedirect, + onError + } = options; + + try { + const data = await api.downloads.getDownloadUrl.query({ + asset_name: assetName + }); + redirect(data.downloadURL); + } catch (error) { + if (onError) onError(error); + } +} diff --git a/src/routes/gaze/index.tsx b/src/routes/gaze/index.tsx new file mode 100644 index 0000000..7dc8246 --- /dev/null +++ b/src/routes/gaze/index.tsx @@ -0,0 +1,175 @@ +import { createSignal } from "solid-js"; +import { PageHead } from "~/components/PageHead"; +import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; +import Button from "~/components/ui/Button"; +import { useDarkMode } from "~/context/darkMode"; +import { downloadAsset } from "~/lib/download-asset"; + +const GAZE_APP_STORE_URL = + "https://apps.apple.com/us/app/gaze/id6757759498"; +const GAZE_MIN_MACOS = "13.0"; + +const FEATURES = [ + { + title: "Take regular breaks", + body: "Gentle, dismissable reminders nudge you to step away from the screen so you can rest and reset." + }, + { + title: "Eye health reminders", + body: "Follow the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds." + }, + { + title: "Posture reminders", + body: "Periodic check-ins help you catch slouching before it becomes a habit." + }, + { + title: "Lives in your menu bar", + body: "A lightweight menu bar app — no dock icon, no intrusive overlays. Just a quiet, reliable companion." + } +] as const; + +export default function GazeLanding() { + const { isDark } = useDarkMode(); + const [loading, setLoading] = createSignal(false); + + const handleDownload = () => { + if (loading()) return; + setLoading(true); + import("~/lib/api") + .then(({ api }) => + downloadAsset({ + api, + assetName: "gaze", + onError: (error) => { + console.error("Gaze download error:", error); + alert("Failed to initiate download. Please try again."); + } + }) + ) + .finally(() => setLoading(false)); + }; + + return ( + <> + + + {/* ── Hero ─────────────────────────────────────────────────────── */} +
+
+ Look away — Gaze hero background +
+ +
+ Gaze App Icon +

Gaze

+

+ Eye and posture health reminder for macOS +

+

+ macOS {GAZE_MIN_MACOS}+ · menu bar app +

+ +
+ + + + +
+

+ Direct download serves the latest signed macOS build. +

+
+
+ + {/* ── Feature highlights ───────────────────────────────────────── */} +
+
+

+ Small reminders, healthier habits +

+
+ {FEATURES.map((feature) => ( +
+

+ {feature.title} +

+

{feature.body}

+
+ ))} +
+
+
+ + {/* ── App preview / CTA ──────────────────────────────────────── */} +
+
+

+ A quieter way to look after yourself +

+

+ Gaze runs quietly in your menu bar, surfacing a gentle, dismissable + reminder when it's time to look away, stretch, or reset your + posture. No accounts, no clunky dashboards — just a steady rhythm + that helps you build better habits. +

+
+ + + + +
+

+ Requires macOS {GAZE_MIN_MACOS} or later. +

+
+
+ + ); +} diff --git a/src/routes/marketing/gaze.tsx b/src/routes/marketing/gaze.tsx index 48c7667..f578877 100644 --- a/src/routes/marketing/gaze.tsx +++ b/src/routes/marketing/gaze.tsx @@ -1,57 +1,21 @@ -import { PageHead } from "~/components/PageHead"; -import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark"; -import { useDarkMode } from "~/context/darkMode"; - -export default function GazeMarketing() { - const { isDark } = useDarkMode(); - - return ( - <> - -
-
- background -
-
-
- Gaze App Icon -
-

Gaze

-

- Eye and posture health reminder for macOS -

-
- - - -
-
-
- - ); +/** + * Legacy `/marketing/gaze` route — redirected (task 05) to the new Gaze + * subdomain landing page at `gaze.freno.me`. + * + * Kept as a permanent 308 redirect so existing inbound links keep resolving + * to the canonical Gaze marketing home. + * + * Implemented as a thrown `Response` (rather than `@solidjs/router`'s + * `redirect()`) because the target is a *cross-origin* absolute URL; throwing + * a `Response` from a SolidStart page component propagates as the actual HTTP + * response, with no router base-path rewriting. + */ +export default function GazeMarketingRedirect(): never { + throw new Response(null, { + status: 308, + headers: { + Location: "https://gaze.freno.me", + "Cache-Control": "public, max-age=86400" + } + }); }