feat(gaze): add Gaze subdomain landing page with direct download
Move the Gaze marketing home from the legacy /marketing/gaze route to a dedicated gaze subdomain landing page (src/routes/gaze). The new page adds a richer hero, feature highlights, and a direct .dmg download flow that resolves a signed S3 URL via the tRPC downloads.getDownloadUrl endpoint. Extract the download-resolution logic into a shared, testable downloadAsset helper (src/lib/download-asset) so current and future subdomain landing pages (InputHalo, Lineage, …) share one code path. The helper is pure over an injected DownloadApi + redirect sink, keeping it unit-testable without importing solid-js / CSRF cookie code. Replace the old marketing/gaze route with a permanent 308 redirect to https://gaze.freno.me so existing inbound links keep resolving to the canonical home. Add unit tests covering the tRPC call shape, redirect sink, and error handling.
This commit is contained in:
88
src/lib/download-asset.test.ts
Normal file
88
src/lib/download-asset.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
76
src/lib/download-asset.ts
Normal file
76
src/lib/download-asset.ts
Normal file
@@ -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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/routes/gaze/index.tsx
Normal file
175
src/routes/gaze/index.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Eye & posture health reminder for macOS"
|
||||||
|
description="Gaze is a macOS menu bar app for eye and posture health — gentle reminders to take breaks, rest your eyes, and sit up straight. Download Gaze for macOS."
|
||||||
|
ogImage="/look-away.png"
|
||||||
|
ogTitle="Gaze — Eye and posture health reminder for macOS"
|
||||||
|
ogDescription="A macOS menu bar app that helps you remember to take breaks and rest your eyes."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
||||||
|
<div class="relative flex min-h-screen flex-col">
|
||||||
|
<div class="fixed inset-0 z-0 overflow-hidden brightness-75">
|
||||||
|
<img
|
||||||
|
src="/look-away.png"
|
||||||
|
alt="Look away — Gaze hero background"
|
||||||
|
class="h-full w-full select-none object-cover"
|
||||||
|
style={{ "pointer-events": "none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center text-white backdrop-blur-sm">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
isDark()
|
||||||
|
? "/Gaze Exports/Gaze-iOS-Dark-1024x1024@1x.png"
|
||||||
|
: "/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png"
|
||||||
|
}
|
||||||
|
alt="Gaze App Icon"
|
||||||
|
height={128}
|
||||||
|
width={128}
|
||||||
|
class="h-32 w-32 rounded-[22%] object-cover object-center shadow-2xl"
|
||||||
|
/>
|
||||||
|
<h1 class="py-4 text-5xl font-bold tracking-tight">Gaze</h1>
|
||||||
|
<p class="mb-2 max-w-xl text-xl text-white/90">
|
||||||
|
Eye and posture health reminder for macOS
|
||||||
|
</p>
|
||||||
|
<p class="mb-8 text-sm text-white/60">
|
||||||
|
macOS {GAZE_MIN_MACOS}+ · menu bar app
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
loading={loading()}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
download.dmg
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
||||||
|
href={GAZE_APP_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadOnAppStoreDark size={50} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-white/50">
|
||||||
|
Direct download serves the latest signed macOS build.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Feature highlights ───────────────────────────────────────── */}
|
||||||
|
<section class="bg-base relative z-20 px-4 py-20 md:px-8">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||||
|
Small reminders, healthier habits
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
|
{FEATURES.map((feature) => (
|
||||||
|
<div
|
||||||
|
class="border-overlay0 bg-surface0 rounded-lg border p-6"
|
||||||
|
>
|
||||||
|
<h3 class="text-text mb-2 text-xl font-semibold">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── App preview / CTA ──────────────────────────────────────── */}
|
||||||
|
<section class="bg-surface0 relative z-20 px-4 py-20 md:px-8">
|
||||||
|
<div class="mx-auto max-w-4xl text-center">
|
||||||
|
<h2 class="text-text mb-4 text-3xl font-bold">
|
||||||
|
A quieter way to look after yourself
|
||||||
|
</h2>
|
||||||
|
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
loading={loading()}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
download.dmg
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
||||||
|
href={GAZE_APP_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadOnAppStoreDark size={50} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext1 mt-6 text-xs">
|
||||||
|
Requires macOS {GAZE_MIN_MACOS} or later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,57 +1,21 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
/**
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
* Legacy `/marketing/gaze` route — redirected (task 05) to the new Gaze
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
* subdomain landing page at `gaze.freno.me`.
|
||||||
|
*
|
||||||
export default function GazeMarketing() {
|
* Kept as a permanent 308 redirect so existing inbound links keep resolving
|
||||||
const { isDark } = useDarkMode();
|
* to the canonical Gaze marketing home.
|
||||||
|
*
|
||||||
return (
|
* Implemented as a thrown `Response` (rather than `@solidjs/router`'s
|
||||||
<>
|
* `redirect()`) because the target is a *cross-origin* absolute URL; throwing
|
||||||
<PageHead
|
* a `Response` from a SolidStart page component propagates as the actual HTTP
|
||||||
title="Gaze - Eye Health Reminder"
|
* response, with no router base-path rewriting.
|
||||||
description="A macOS menu bar app that helps you remember to take breaks and rest your eyes. Download Gaze today."
|
*/
|
||||||
/>
|
export default function GazeMarketingRedirect(): never {
|
||||||
<div class="relative h-full">
|
throw new Response(null, {
|
||||||
<div class="fixed inset-0 z-0 overflow-hidden brightness-75">
|
status: 308,
|
||||||
<img
|
headers: {
|
||||||
src="/look-away.png"
|
Location: "https://gaze.freno.me",
|
||||||
alt="background"
|
"Cache-Control": "public, max-age=86400"
|
||||||
class="h-full w-full object-cover select-none"
|
}
|
||||||
style={{
|
});
|
||||||
"pointer-events": "none"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="relative z-10 flex h-full flex-col items-center justify-center text-white backdrop-blur">
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
isDark()
|
|
||||||
? "/Gaze Exports/Gaze-iOS-Dark-1024x1024@1x.png"
|
|
||||||
: "/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png"
|
|
||||||
}
|
|
||||||
alt="Gaze App Icon"
|
|
||||||
height={128}
|
|
||||||
width={128}
|
|
||||||
class="object-cover object-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 class="py-4 text-center text-5xl font-bold">Gaze</h1>
|
|
||||||
<p class="text-text mb-8 text-xl">
|
|
||||||
Eye and posture health reminder for macOS
|
|
||||||
</p>
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<a
|
|
||||||
class="my-auto transition-all duration-200 ease-out active:scale-95"
|
|
||||||
href="https://apps.apple.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<DownloadOnAppStoreDark size={50} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user