prep for the nook

This commit is contained in:
2026-08-26 17:41:57 -04:00
parent 898c891bd5
commit e4a9be4899
27 changed files with 1234 additions and 20 deletions

View File

@@ -0,0 +1,110 @@
import { createSignal, onMount } from "solid-js";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import { env } from "~/env/client";
import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode";
export default function NookCheckout() {
const site = useSite();
const { isDark } = useDarkMode();
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal("");
const [turnstileToken, setTurnstileToken] = createSignal("");
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
onMount(() => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
const container = document.getElementById("turnstile-widget-nook");
if (container && (window as any).turnstile) {
(window as any).turnstile.render(container, {
sitekey: env.VITE_TURNSTILE_SITE_KEY,
theme: isDark() ? "dark" : "light",
callback: (token: string) => setTurnstileToken(token),
"expired-callback": () => setTurnstileToken("")
});
}
};
document.head.appendChild(script);
});
const buy = async () => {
if (loading()) return;
if (!turnstileToken()) {
setError("Complete the security check to continue.");
return;
}
setLoading(true);
setError("");
try {
const res = await fetch("/api/the-nook/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ turnstileToken: turnstileToken() })
});
const data = (await res.json()) as { checkoutUrl?: string; error?: string };
if (!res.ok || !data.checkoutUrl) {
setError(data.error ?? "Unable to start checkout. Please try again.");
return;
}
window.location.href = data.checkoutUrl;
} catch {
setError("Unable to start checkout. Please try again.");
} finally {
setLoading(false);
}
};
return (
<>
<PageHead
title="Checkout"
description="Buy a The Nook license — $10 one-time for up to 3 devices."
/>
<SubdomainHeader />
<div class="bg-base mx-auto max-w-lg px-4 py-16">
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
<h1 class="text-text mb-1 text-3xl font-bold">The Nook</h1>
<p class="text-subtext0 mb-6">Lifetime License — 3 devices</p>
<div class="text-text mb-6 text-4xl font-extrabold">
$10<span class="text-subtext0 text-base font-normal"> one-time</span>
</div>
<ul class="text-subtext0 mb-6 space-y-2 text-sm">
<li>• Activate on up to 3 of your own Macs</li>
<li>• No subscription, ever</li>
<li>• Key delivered by email + on this page</li>
</ul>
<div id="turnstile-widget-nook" class="mb-4" />
<Button
variant="download"
size="lg"
color={brandColor()}
fullWidth
loading={loading()}
onClick={buy}
>
Pay $10
</Button>
{error() && (
<p class="text-red mt-4 text-sm">{error()}</p>
)}
<p class="text-subtext1 mt-4 text-xs">
Billed once through Stripe. License delivered immediately after payment.
</p>
</div>
</div>
</>
);
}

147
src/routes/nook/index.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import { useDarkMode } from "~/context/darkMode";
import { useSite } from "~/context/SiteContext";
const NOOK_DOWNLOAD_URL = "https://freno.me/api/downloads/TheNook-0.2.0.zip";
const FEATURES = [
{
title: "Unified device control",
body: "Monitor and control the hardware and services behind your Mac from one fast, native panel."
},
{
title: "Fan & thermal insight",
body: "Read and control system fans, watch temperatures, and keep performance predictable under load."
},
{
title: "Private by design",
body: "Runs fully on your machine with no mandatory accounts. Your hardware data never leaves the device."
},
{
title: "One-time license",
body: "Pay once, activate on up to three of your own Macs. No subscriptions, no forced renewals."
}
] as const;
export default function NookLanding() {
const site = useSite();
const { isDark } = useDarkMode();
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
return (
<>
<PageHead
title="Home"
description="The Nook — a native macOS utility for hardware control, fan and thermal insight, and quiet system services."
ogImage="/nook/og-default.png"
ogTitle="The Nook — native macOS hardware control"
ogDescription="A one-time-purchase macOS app for fan control, thermal insight, and quiet system services."
/>
<SubdomainHeader />
{/* ── Hero ─────────────────────────────────────────────────────── */}
<div class="relative flex min-h-screen flex-col overflow-hidden">
<div
class="fixed inset-0 z-0"
style={{
background: isDark()
? "radial-gradient(ellipse at top, #241a38 0%, #0b0b10 70%)"
: "radial-gradient(ellipse at top, #efe9ff 0%, #f5f5f5 70%)"
}}
/>
<div class="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-24 text-center">
<div class="text-text/90 mb-6 rounded-2xl px-5 py-3 text-sm font-semibold tracking-wide backdrop-blur-sm"
style={{ border: "1px solid var(--color-overlay0)", background: "var(--color-surface0)" }}>
The Nook
</div>
<h1 class="text-text mb-4 text-5xl font-bold tracking-tight">
Your Mac, under your control
</h1>
<p class="text-subtext0 mb-2 max-w-xl text-xl">
Fan control, thermal insight, and quiet system services — native macOS, one-time license.
</p>
<p class="text-subtext1 mb-8 text-sm">
macOS 14+ · 14-day free trial · 3 devices
</p>
<div class="flex flex-col items-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
>
Download trial
</Button>
<Button
variant="secondary"
size="lg"
onClick={() => (window.location.href = "/checkout")}
>
Buy
</Button>
</div>
<p class="text-subtext1 mt-3 text-xs">
$10 one-time · 3 devices · 14-day free trial
</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">
Quiet power, right where it belongs
</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg: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>
{/* ── 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">
Try it free for 14 days
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
Watch temperatures, take control of your fans, and keep background
services quiet. When the trial ends, unlock everything with a
single one-time payment — no subscription, ever.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button
variant="download"
size="lg"
color={brandColor()}
onClick={() => (window.location.href = NOOK_DOWNLOAD_URL)}
>
Download trial
</Button>
<a
class="text-subtext1 my-auto text-sm underline decoration-dotted hover:opacity-80"
href="/checkout"
>
Buy a license → $10
</a>
</div>
<p class="text-subtext1 mt-6 text-xs">
One license covers up to 3 of your own Macs.
</p>
</div>
</section>
</>
);
}

107
src/routes/nook/privacy.tsx Normal file
View File

@@ -0,0 +1,107 @@
/**
* The Nook privacy policy — `nook.freno.me/privacy`.
*
* Reflects the actual data The Nook sends when licensing: a masked hardware
* UUID + device name for trial/activation limits, the email used for purchase,
* and Stripe payment processing.
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function NookPrivacyPolicy() {
return (
<>
<PageHead
title="Privacy Policy"
description="Privacy policy for The Nook, a native macOS hardware and system control app."
/>
<SubdomainHeader />
<div class="min-h-screen px-[8vw] py-[10vh]">
<div class="py-4 text-xl">The Nook&apos;s Privacy Policy</div>
<div class="py-2">Last Updated: August 26, 2026</div>
<div class="py-2">
Welcome to The Nook (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
Your privacy is important to us. This policy explains what we collect,
why, and how it is handled. The app itself runs locally on your device
and sends only the minimal data required for trial and licensing.
</div>
<ol>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">1.</span> Data We Collect
</div>
<div class="pl-4">
<div class="pb-2">
<div class="-ml-6">(a) Device hardware UUID:</div> When you start
a trial or activate a license, The Nook sends a hardware UUID
from your Mac. It is used solely to enforce the 14-day trial and
the 3-device activation limit, and to prevent resetting the
trial by reinstalling the app.
</div>
<div class="pb-2">
<div class="-ml-6">(b) Device name:</div> The Nook sends your
Mac&apos;s name so you can recognize the device in your license
activations. It is not used for any other purpose.
</div>
<div class="pb-2">
<div class="-ml-6">(c) Email:</div> The email you provide at
checkout is used to deliver your license key and to look up your
order.
</div>
<div class="pb-2">
<div class="-ml-6">(d) Payment:</div> Payments are processed by
Stripe. We never see or store your card details.
</div>
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">2.</span> How We Use It
</div>
<div class="pb-2 pl-4">
Device identifiers, your email, and license records are used only
to operate the trial, deliver purchases, and enforce the 3-device
license. We do not use them for advertising or sell them to anyone.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">3.</span> Data Security
</div>
<div class="pb-2 pl-4">
License keys are signed and verified with Ed25519. The connection
to our servers uses TLS. Your license key is stored in the macOS
Keychain on your Mac.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">4.</span> Changes to the Privacy Policy
</div>
<div class="pb-2 pl-4">
We may update this policy periodically. Any changes are posted on
this page, so please review it from time to time.
</div>
</div>
<div class="py-2">
<div class="pb-2 text-lg">
<span class="-ml-4 pr-2">5.</span> Contact Us
</div>
<div class="pb-2 pl-4">
If you have any questions about this policy, you can contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation">
here
</A>
.
</div>
</div>
</ol>
</div>
</>
);
}

112
src/routes/nook/success.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { createSignal, Show, onMount } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import { useSite } from "~/context/SiteContext";
import { useDarkMode } from "~/context/darkMode";
const POLL_INTERVAL_MS = 1500;
const MAX_POLLS = 8;
export default function NookSuccess() {
const site = useSite();
const { isDark } = useDarkMode();
const [searchParams] = useSearchParams();
const sessionId = () => (searchParams.session_id as string | undefined) ?? "";
const brandColor = () =>
isDark() ? (site().brandColorDark ?? site().brandColor) : site().brandColor;
const [key, setKey] = createSignal<string | null>(null);
const [failed, setFailed] = createSignal(false);
const [copied, setCopied] = createSignal(false);
const copyKey = async () => {
const value = key();
if (!value) return;
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
onMount(async () => {
if (!sessionId()) {
setFailed(true);
return;
}
for (let i = 0; i < MAX_POLLS; i++) {
try {
const res = await fetch(
`/api/the-nook/by-session?session_id=${encodeURIComponent(sessionId())}`
);
if (res.ok) {
const data = (await res.json()) as { key: string };
setKey(data.key);
return;
}
} catch {
// transient — keep polling
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
setFailed(true);
});
return (
<>
<PageHead
title="Order complete"
description="Your The Nook license key."
/>
<SubdomainHeader />
<div class="bg-base mx-auto max-w-xl px-4 py-16">
<div class="border-overlay0 bg-surface0 rounded-xl border p-8 shadow-2xl">
<Show
when={key()}
fallback={
<Show
when={failed()}
fallback={
<div>
<h1 class="text-text mb-2 text-2xl font-bold">
Confirming your order…
</h1>
<p class="text-subtext0 text-sm">
Checking payment and preparing your license key.
</p>
</div>
}
>
<div>
<h1 class="text-text mb-2 text-2xl font-bold">Thank you</h1>
<p class="text-subtext0 text-sm">
Check your email for your license key.
</p>
</div>
</Show>
}
>
<h1 class="text-text mb-2 text-2xl font-bold">Thanks for buying The Nook</h1>
<p class="text-subtext0 mb-6 text-sm">
Here is your license key. Open The Nook, go to Settings, and enter it to activate.
</p>
<div class="border-overlay0 bg-base mb-4 flex items-center justify-between gap-3 rounded-lg border p-4">
<code class="text-text break-all text-sm">{key()}</code>
</div>
<Button variant="download" size="md" color={brandColor()} onClick={copyKey}>
{copied() ? "Copied" : "Copy key"}
</Button>
<p class="text-subtext1 mt-4 text-xs">
We also emailed this key to you. You can activate up to 3 devices.
</p>
</Show>
</div>
</div>
</>
);
}