fix: product subdomain cleanup
21
AGENTS.md
@@ -1,6 +1,7 @@
|
|||||||
# Agent Guidelines for freno-dev
|
# Agent Guidelines for freno-dev
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: SolidJS with SolidStart (Vinxi)
|
- **Framework**: SolidJS with SolidStart (Vinxi)
|
||||||
- **Routing**: @solidjs/router
|
- **Routing**: @solidjs/router
|
||||||
- **API**: tRPC v10 with Zod validation
|
- **API**: tRPC v10 with Zod validation
|
||||||
@@ -9,20 +10,33 @@
|
|||||||
- **Runtime**: Bun (Node >=22)
|
- **Runtime**: Bun (Node >=22)
|
||||||
- **Deployment**: Vercel preset
|
- **Deployment**: Vercel preset
|
||||||
|
|
||||||
|
## Content Rules
|
||||||
|
|
||||||
|
### No Competitor Mentions
|
||||||
|
|
||||||
|
- **Never mention a competitor's product by name** in any user-facing content (landing pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, etc.).
|
||||||
|
- This includes but is not limited to products like Strava, Garmin, etc.
|
||||||
|
- Describe the product's own merits and positioning on its own terms. Use generic descriptions instead of naming competitors.
|
||||||
|
- This applies to ALL products (Nessa, Lineage, Gaze, InputHalo) and the main site.
|
||||||
|
- If existing content already names a competitor, remove or generalize it when working in that file.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
||||||
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
||||||
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
||||||
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
||||||
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
||||||
- Group imports: external deps → solid-js → local (~/)
|
- Group imports: external deps → solid-js → local (~/)
|
||||||
|
|
||||||
### SolidJS Patterns (NOT React!)
|
### SolidJS Patterns (NOT React!)
|
||||||
|
|
||||||
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
||||||
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
||||||
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
||||||
@@ -31,6 +45,7 @@
|
|||||||
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
- **Strict mode enabled** - always type function params and returns
|
- **Strict mode enabled** - always type function params and returns
|
||||||
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
||||||
- Use `splitProps()` for component prop destructuring
|
- Use `splitProps()` for component prop destructuring
|
||||||
@@ -38,6 +53,7 @@
|
|||||||
- Database types: Cast with `as unknown as User` for SQL results
|
- Database types: Cast with `as unknown as User` for SQL results
|
||||||
|
|
||||||
### API/Server Patterns
|
### API/Server Patterns
|
||||||
|
|
||||||
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
||||||
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
||||||
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
||||||
@@ -46,17 +62,20 @@
|
|||||||
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Use TRPCError with semantic codes on server
|
- Use TRPCError with semantic codes on server
|
||||||
- Validate inputs with Zod schemas before processing
|
- Validate inputs with Zod schemas before processing
|
||||||
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
||||||
- Return structured responses: `{ success: boolean, message?: string }`
|
- Return structured responses: `{ success: boolean, message?: string }`
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
- **Minimal comments** - prefer self-documenting code
|
- **Minimal comments** - prefer self-documenting code
|
||||||
- JSDoc for exported functions/components only
|
- JSDoc for exported functions/components only
|
||||||
- Inline comments for non-obvious logic only
|
- Inline comments for non-obvious logic only
|
||||||
|
|
||||||
### File Organization
|
### File Organization
|
||||||
|
|
||||||
- Routes in `src/routes/` (file-based routing)
|
- Routes in `src/routes/` (file-based routing)
|
||||||
- Components in `src/components/` (reusable) or co-located with routes
|
- Components in `src/components/` (reusable) or co-located with routes
|
||||||
- API routers in `src/server/api/routers/`
|
- API routers in `src/server/api/routers/`
|
||||||
@@ -73,7 +92,9 @@ This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me
|
|||||||
- **Auth:** Host-scoped only — no cookie domain broadening.
|
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||||
|
|
||||||
## Key Differences from React
|
## Key Differences from React
|
||||||
|
|
||||||
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
||||||
|
|
||||||
- Signals must be called with `()` to read value
|
- Signals must be called with `()` to read value
|
||||||
- `onChange` → `onInput` for real-time input updates
|
- `onChange` → `onInput` for real-time input updates
|
||||||
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
||||||
|
|||||||
BIN
public/Nessa Exports/01-home-tab.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/Nessa Exports/04-plans-tab-strength.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
public/Nessa Exports/09-clubs-list.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/Nessa Exports/29-apple-health.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/Nessa Exports/38-workout-summary.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/Nessa Exports/40-segment-list.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Default-1024x1024.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
27
src/app.tsx
@@ -5,6 +5,7 @@ import {
|
|||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
onMount,
|
onMount,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
|
Show,
|
||||||
Suspense
|
Suspense
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
@@ -15,7 +16,7 @@ import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
|||||||
import { BarsProvider, useBars } from "./context/bars";
|
import { BarsProvider, useBars } from "./context/bars";
|
||||||
import { DarkModeProvider } from "./context/darkMode";
|
import { DarkModeProvider } from "./context/darkMode";
|
||||||
import { AuthProvider } from "./context/auth";
|
import { AuthProvider } from "./context/auth";
|
||||||
import { SiteProvider } from "./context/SiteContext";
|
import { SiteProvider, useSite } from "./context/SiteContext";
|
||||||
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
||||||
import { MOBILE_CONFIG } from "./config";
|
import { MOBILE_CONFIG } from "./config";
|
||||||
import CustomScrollbar from "./components/CustomScrollbar";
|
import CustomScrollbar from "./components/CustomScrollbar";
|
||||||
@@ -155,8 +156,12 @@ function AppLayout(props: { children: any }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const site = useSite();
|
||||||
|
const isMainSite = () => site().id === "main";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Show when={isMainSite()}>
|
||||||
<div class="flex max-w-screen flex-row overflow-x-hidden">
|
<div class="flex max-w-screen flex-row overflow-x-hidden">
|
||||||
<LeftBar />
|
<LeftBar />
|
||||||
<div
|
<div
|
||||||
@@ -191,6 +196,26 @@ function AppLayout(props: { children: any }) {
|
|||||||
</div>
|
</div>
|
||||||
<RightBar />
|
<RightBar />
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!isMainSite()}>
|
||||||
|
<div class="bg-base min-h-screen w-full overflow-x-hidden">
|
||||||
|
<noscript>
|
||||||
|
<div class="bg-yellow text-crust border-text fixed top-0 z-150 w-full border-b-2 p-4 text-center font-semibold">
|
||||||
|
JavaScript is disabled. Features will be limited.
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={(error, reset) => (
|
||||||
|
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense fallback={<TerminalSplash inverse />}>
|
||||||
|
{props.children}
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/components/SubdomainHeader.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Minimal top navigation for product subdomains.
|
||||||
|
*
|
||||||
|
* Replaces the dual-left/right sidebar used on `freno.me` for each product
|
||||||
|
* subdomain (`nessa.*`, `lineage.*`, `gaze.*`, `inputhalo.*`). The header is
|
||||||
|
* sticky, site-aware, and derives its links from `NAV_CONFIG` so the nav set
|
||||||
|
* remains the single source of truth. `lineage.freno.me/` intentionally does
|
||||||
|
* not use this component so the original full-bleed parallax landing page is
|
||||||
|
* preserved.
|
||||||
|
*/
|
||||||
|
import { For, Show } from "solid-js";
|
||||||
|
import { A, useLocation } from "@solidjs/router";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config";
|
||||||
|
|
||||||
|
export default function SubdomainHeader() {
|
||||||
|
const site = useSite();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const brandName = () => site().displayName;
|
||||||
|
const brandColor = () => site().brandColor;
|
||||||
|
const navItems = () =>
|
||||||
|
NAV_CONFIG[site().id].filter((item) => item.label !== "Home");
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
const path = location.pathname;
|
||||||
|
return href === "/" ? path === "/" : path === href;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md">
|
||||||
|
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||||
|
<A
|
||||||
|
href="/"
|
||||||
|
class="text-lg font-bold tracking-tight transition-opacity hover:opacity-80"
|
||||||
|
style={{ color: brandColor() }}
|
||||||
|
>
|
||||||
|
{brandName()}
|
||||||
|
</A>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label={`${brandName()} navigation`}
|
||||||
|
class="flex items-center gap-4 overflow-x-auto text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<For each={navItems()}>
|
||||||
|
{(item) => (
|
||||||
|
<Show
|
||||||
|
when={item.external}
|
||||||
|
fallback={
|
||||||
|
<A
|
||||||
|
href={item.href}
|
||||||
|
end
|
||||||
|
class="transition-opacity hover:opacity-80"
|
||||||
|
classList={{
|
||||||
|
"font-semibold": isActive(item.href),
|
||||||
|
"opacity-70": !isActive(item.href)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</A>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="opacity-70 transition-opacity hover:opacity-100"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={BACK_TO_FRENO.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-text/60 hover:text-text text-xs transition-colors"
|
||||||
|
>
|
||||||
|
{BACK_TO_FRENO.label}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
* - Client (hydration): reads the SSR-injected `window.__SITE__` id (written
|
* - Client (hydration): reads the SSR-injected `window.__SITE__` id (written
|
||||||
* into the document shell by `entry-server.tsx`) so the post-hydration
|
* into the document shell by `entry-server.tsx`) so the post-hydration
|
||||||
* value matches the `data-site` attribute on `<html>`. Falls back to
|
* value matches the `data-site` attribute on `<html>`. Falls back to
|
||||||
* `resolveSiteFromHost(window.location.hostname)` if the injected id is
|
* `resolveSiteFromLocation(window.location.hostname, window.location.pathname)`
|
||||||
* missing (e.g. client-side navigation / hard refresh quirks).
|
* if the injected id is missing — the pathname fallback covers localhost
|
||||||
|
* dev where the host is `localhost` but the URL still carries a subdomain
|
||||||
|
* prefix (`/nessa/contact`).
|
||||||
*
|
*
|
||||||
* NOTE on race-safety: SSR of a personal site is single-render-per-request in
|
* NOTE on race-safety: SSR of a personal site is single-render-per-request in
|
||||||
* practice; the module-level holder is adequate here. Server functions that
|
* practice; the module-level holder is adequate here. Server functions that
|
||||||
@@ -29,6 +31,7 @@ import { isServer } from "solid-js/web";
|
|||||||
import {
|
import {
|
||||||
resolveSiteFromHost,
|
resolveSiteFromHost,
|
||||||
resolveSiteFromLocation,
|
resolveSiteFromLocation,
|
||||||
|
resolveSiteFromPath,
|
||||||
SITE_CONFIG,
|
SITE_CONFIG,
|
||||||
MAIN_SITE,
|
MAIN_SITE,
|
||||||
type Site,
|
type Site,
|
||||||
@@ -59,7 +62,13 @@ export function resolveClientSite(): Site {
|
|||||||
if (typeof window === "undefined") return MAIN_SITE;
|
if (typeof window === "undefined") return MAIN_SITE;
|
||||||
const injected = window.__SITE__;
|
const injected = window.__SITE__;
|
||||||
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
|
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
|
||||||
return resolveSiteFromLocation(window.location.hostname);
|
// Fall back to hostname + URL-path prefix. On localhost (dev) the host is
|
||||||
|
// `localhost` (→ main) but the path carries the subdomain prefix
|
||||||
|
// (`/nessa/contact` → nessa), so we must consider both.
|
||||||
|
return resolveSiteFromLocation(
|
||||||
|
window.location.hostname,
|
||||||
|
window.location.pathname
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Context ──────────────────────────────────────────────────────────────
|
// ── Context ──────────────────────────────────────────────────────────────
|
||||||
@@ -101,4 +110,4 @@ export function useSite(): Accessor<Site> {
|
|||||||
|
|
||||||
export { SITE_CONFIG, MAIN_SITE };
|
export { SITE_CONFIG, MAIN_SITE };
|
||||||
export type { Site, SiteId };
|
export type { Site, SiteId };
|
||||||
export { resolveSiteFromHost, resolveSiteFromLocation };
|
export { resolveSiteFromHost, resolveSiteFromLocation, resolveSiteFromPath };
|
||||||
|
|||||||
@@ -16,13 +16,7 @@ import {
|
|||||||
} from "./nav-config";
|
} from "./nav-config";
|
||||||
import { SITE_CONFIG, type SiteId } from "./site-context";
|
import { SITE_CONFIG, type SiteId } from "./site-context";
|
||||||
|
|
||||||
const ALL_SITES: SiteId[] = [
|
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo"];
|
||||||
"main",
|
|
||||||
"nessa",
|
|
||||||
"lineage",
|
|
||||||
"gaze",
|
|
||||||
"inputhalo"
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("NAV_CONFIG — per-site link sets", () => {
|
describe("NAV_CONFIG — per-site link sets", () => {
|
||||||
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
||||||
@@ -55,12 +49,22 @@ describe("NAV_CONFIG — per-site link sets", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gaze → Home, Contact, Privacy", () => {
|
it("gaze → Home, Contact, Privacy, Downloads", () => {
|
||||||
expect(navLabelsFor("gaze")).toEqual(["Home", "Contact", "Privacy"]);
|
expect(navLabelsFor("gaze")).toEqual([
|
||||||
|
"Home",
|
||||||
|
"Contact",
|
||||||
|
"Privacy",
|
||||||
|
"Downloads"
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("inputhalo → Home, Contact, Privacy", () => {
|
it("inputhalo → Home, Contact, Privacy, Downloads", () => {
|
||||||
expect(navLabelsFor("inputhalo")).toEqual(["Home", "Contact", "Privacy"]);
|
expect(navLabelsFor("inputhalo")).toEqual([
|
||||||
|
"Home",
|
||||||
|
"Contact",
|
||||||
|
"Privacy",
|
||||||
|
"Downloads"
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export const BACK_TO_FRENO: NavItem = {
|
|||||||
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
|
* - main: Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn
|
||||||
* - nessa: Home, Contact, Privacy
|
* - nessa: Home, Contact, Privacy
|
||||||
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
|
* - lineage: Home, Downloads, Contact, Privacy, Account Deletion
|
||||||
* - gaze: Home, Contact, Privacy
|
* - gaze: Home, Contact, Privacy, Downloads
|
||||||
* - inputhalo: Home, Contact, Privacy
|
* - inputhalo: Home, Contact, Privacy, Downloads
|
||||||
*/
|
*/
|
||||||
export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
||||||
main: [
|
main: [
|
||||||
@@ -107,12 +107,14 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
|||||||
gaze: [
|
gaze: [
|
||||||
{ label: "Home", href: "/", icon: "home" },
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
||||||
|
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
||||||
],
|
],
|
||||||
inputhalo: [
|
inputhalo: [
|
||||||
{ label: "Home", href: "/", icon: "home" },
|
{ label: "Home", href: "/", icon: "home" },
|
||||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
{ label: "Privacy", href: "/privacy", icon: "privacy" },
|
||||||
|
{ label: "Downloads", href: "/downloads", icon: "downloads" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import {
|
import {
|
||||||
resolveSiteFromHost,
|
resolveSiteFromHost,
|
||||||
|
resolveSiteFromPath,
|
||||||
|
resolveSiteFromLocation,
|
||||||
SITE_CONFIG,
|
SITE_CONFIG,
|
||||||
type SiteId
|
type SiteId
|
||||||
} from "./site-context";
|
} from "./site-context";
|
||||||
@@ -80,15 +82,12 @@ describe("resolveSiteFromHost", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
|
it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => {
|
||||||
for (const id of [
|
for (const id of ["nessa", "lineage", "gaze", "inputhalo"] as SiteId[]) {
|
||||||
"nessa",
|
|
||||||
"lineage",
|
|
||||||
"gaze",
|
|
||||||
"inputhalo"
|
|
||||||
] as SiteId[]) {
|
|
||||||
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
|
expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`);
|
||||||
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
expect(SITE_CONFIG[id].subdomain).toBe(id);
|
||||||
expect(SITE_CONFIG[id].titleSuffix).toBe(` | ${SITE_CONFIG[id].displayName}`);
|
expect(SITE_CONFIG[id].titleSuffix).toBe(
|
||||||
|
` | ${SITE_CONFIG[id].displayName}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,3 +96,67 @@ describe("resolveSiteFromHost", () => {
|
|||||||
expect(SITE_CONFIG.main.baseRoutePrefix).toBe("");
|
expect(SITE_CONFIG.main.baseRoutePrefix).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveSiteFromPath", () => {
|
||||||
|
it("maps a subdomain-prefixed path to the right site", () => {
|
||||||
|
expect(resolveSiteFromPath("/nessa/contact")?.id).toBe("nessa");
|
||||||
|
expect(resolveSiteFromPath("/lineage/downloads")?.id).toBe("lineage");
|
||||||
|
expect(resolveSiteFromPath("/gaze/")?.id).toBe("gaze");
|
||||||
|
expect(resolveSiteFromPath("/inputhalo/privacy")?.id).toBe("inputhalo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the exact prefix (landing page)", () => {
|
||||||
|
expect(resolveSiteFromPath("/nessa")?.id).toBe("nessa");
|
||||||
|
expect(resolveSiteFromPath("/gaze")?.id).toBe("gaze");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-subdomain paths", () => {
|
||||||
|
expect(resolveSiteFromPath("/")).toBeNull();
|
||||||
|
expect(resolveSiteFromPath("/contact")).toBeNull();
|
||||||
|
expect(resolveSiteFromPath("/blog/post")).toBeNull();
|
||||||
|
expect(resolveSiteFromPath("/downloads")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match prefix substrings (no false positives)", () => {
|
||||||
|
// `/nessa-extra` must NOT match `/nessa`.
|
||||||
|
expect(resolveSiteFromPath("/nessa-extra")).toBeNull();
|
||||||
|
expect(resolveSiteFromPath("/gazette")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty / null / undefined", () => {
|
||||||
|
expect(resolveSiteFromPath("")).toBeNull();
|
||||||
|
expect(resolveSiteFromPath(null)).toBeNull();
|
||||||
|
expect(resolveSiteFromPath(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the full SITE_CONFIG entry", () => {
|
||||||
|
expect(resolveSiteFromPath("/nessa/contact")).toEqual(SITE_CONFIG.nessa);
|
||||||
|
expect(resolveSiteFromPath("/gaze")).toEqual(SITE_CONFIG.gaze);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSiteFromLocation", () => {
|
||||||
|
it("prefers the host when it identifies a subdomain", () => {
|
||||||
|
expect(resolveSiteFromLocation("nessa.freno.me", "/contact").id).toBe(
|
||||||
|
"nessa"
|
||||||
|
);
|
||||||
|
expect(resolveSiteFromLocation("gaze.localhost", "/").id).toBe("gaze");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the path prefix when the host is localhost/main", () => {
|
||||||
|
expect(resolveSiteFromLocation("localhost", "/nessa/contact").id).toBe(
|
||||||
|
"nessa"
|
||||||
|
);
|
||||||
|
expect(resolveSiteFromLocation("localhost", "/lineage/").id).toBe(
|
||||||
|
"lineage"
|
||||||
|
);
|
||||||
|
expect(resolveSiteFromLocation("freno.me", "/gaze/downloads").id).toBe(
|
||||||
|
"gaze"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns main when neither host nor path identifies a subdomain", () => {
|
||||||
|
expect(resolveSiteFromLocation("localhost", "/contact").id).toBe("main");
|
||||||
|
expect(resolveSiteFromLocation("freno.me", "/").id).toBe("main");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -159,13 +159,54 @@ export function resolveSiteFromHost(host: string | null | undefined): Site {
|
|||||||
return MAIN_SITE;
|
return MAIN_SITE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `Site` from a URL pathname by matching a known subdomain route
|
||||||
|
* prefix (e.g. `/nessa/contact` → nessa, `/gaze/downloads` → gaze).
|
||||||
|
*
|
||||||
|
* Returns `null` when the path does not begin with a subdomain prefix so the
|
||||||
|
* caller can distinguish "no match" from "main" and decide whether to fall
|
||||||
|
* back to the host-based result.
|
||||||
|
*
|
||||||
|
* This is the dev-server safety net: on `localhost:3000/nessa/contact` the
|
||||||
|
* Host header is `localhost` (→ main), but the URL path still carries the
|
||||||
|
* subdomain prefix because vercel.json host rewrites do not run locally.
|
||||||
|
* Without this fallback, `useSite()` returns `main` on every subdomain page
|
||||||
|
* in dev, causing `<SubdomainHeader>` to render the main-site nav.
|
||||||
|
*
|
||||||
|
* In production the host rewrite strips the prefix, so the path is `/contact`
|
||||||
|
* (no prefix) and this returns `null` — the host-based result already
|
||||||
|
* resolved correctly.
|
||||||
|
*
|
||||||
|
* Pure & synchronous — no I/O, no env access.
|
||||||
|
*/
|
||||||
|
export function resolveSiteFromPath(
|
||||||
|
pathname: string | null | undefined
|
||||||
|
): Site | null {
|
||||||
|
if (!pathname) return null;
|
||||||
|
for (const site of SUBDOMAIN_SITES) {
|
||||||
|
const prefix = site.baseRoutePrefix; // e.g. "/nessa"
|
||||||
|
// Exact prefix (`/nessa`) or prefix + `/` (`/nessa/contact`).
|
||||||
|
if (pathname === prefix || pathname.startsWith(prefix + "/")) {
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active site from a client `window.location`, used by the
|
* Resolve the active site from a client `window.location`, used by the
|
||||||
* SolidJS `SiteContext` provider during hydration. Server codepaths should
|
* SolidJS `SiteContext` provider during hydration. Server codepaths should
|
||||||
* use `getSiteFromEvent` / `getSiteFromRequest` instead.
|
* use `getSiteFromEvent` / `getSiteFromRequest` instead.
|
||||||
|
*
|
||||||
|
* When the hostname does not identify a subdomain (e.g. `localhost` in dev),
|
||||||
|
* falls back to checking the URL pathname for a subdomain route prefix so
|
||||||
|
* that `localhost:3000/nessa/contact` resolves to nessa.
|
||||||
*/
|
*/
|
||||||
export function resolveSiteFromLocation(
|
export function resolveSiteFromLocation(
|
||||||
hostname: string | null | undefined
|
hostname: string | null | undefined,
|
||||||
|
pathname?: string | null | undefined
|
||||||
): Site {
|
): Site {
|
||||||
return resolveSiteFromHost(hostname);
|
const hostResult = resolveSiteFromHost(hostname);
|
||||||
|
if (hostResult.id !== "main") return hostResult;
|
||||||
|
return resolveSiteFromPath(pathname) ?? hostResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gaze contact page (`gaze.freno.me/contact`).
|
* Gaze contact page (`gaze.freno.me/contact`).
|
||||||
@@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm";
|
|||||||
* `[Gaze] Contact Request`.
|
* `[Gaze] Contact Request`.
|
||||||
*/
|
*/
|
||||||
export default function GazeContactPage() {
|
export default function GazeContactPage() {
|
||||||
return <ContactForm />;
|
return (
|
||||||
|
<>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<ContactForm />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
130
src/routes/gaze/downloads.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Gaze per-subdomain downloads page — `gaze.freno.me/downloads`.
|
||||||
|
*
|
||||||
|
* Public browser path `/downloads`; vercel.json host rewrites serve this
|
||||||
|
* from the internal `/gaze/*` route prefix while keeping the URL clean.
|
||||||
|
*
|
||||||
|
* Mirrors the download surface already exposed on the Gaze landing page:
|
||||||
|
* - Direct signed-S3 DMG download via `downloads.getDownloadUrl({ asset_name: "gaze" })`.
|
||||||
|
* - macOS App Store listing link.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
|
import Button from "~/components/ui/Button";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { downloadAsset } from "~/lib/download-asset";
|
||||||
|
|
||||||
|
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
|
||||||
|
const GAZE_MIN_MACOS = "14.6";
|
||||||
|
|
||||||
|
export default function GazeDownloadsPage() {
|
||||||
|
const site = useSite();
|
||||||
|
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="Download Gaze"
|
||||||
|
description="Download Gaze for macOS — menu bar app for eye and posture health."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
|
<main class="bg-base relative min-h-screen w-full overflow-hidden px-4 pb-16">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-0 z-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(50% 40% at 50% 0%, ${site().brandColor}18 0%, transparent 70%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="relative z-10 mx-auto flex max-w-2xl flex-col items-center pt-[12vh] text-center">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
site().id === "gaze"
|
||||||
|
? "/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
alt="Gaze app icon"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
class="mb-6 h-28 w-28 rounded-[22%] object-cover shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 class="mb-2 text-4xl font-bold tracking-tight">Download Gaze</h1>
|
||||||
|
<p class="text-text/70 mb-10 max-w-md text-lg">
|
||||||
|
Eye and posture health reminders for your Mac menu bar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col items-center justify-center gap-8 sm:flex-row">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<span class="text-subtext0 text-sm tracking-wider uppercase">
|
||||||
|
Direct download
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
loading={loading()}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
gaze.dmg
|
||||||
|
</Button>
|
||||||
|
<span class="text-subtext1 max-w-[240px] text-center text-xs">
|
||||||
|
macOS {GAZE_MIN_MACOS}+ · signed macOS build
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-surface0/40 hidden h-24 w-px sm:block" />
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<span class="text-subtext0 text-sm tracking-wider uppercase">
|
||||||
|
App Store
|
||||||
|
</span>
|
||||||
|
<A
|
||||||
|
class="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>
|
||||||
|
<span class="text-subtext1 max-w-[240px] text-center text-xs">
|
||||||
|
Also available on the Mac App Store.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-subtext0 mt-16 text-center text-sm">
|
||||||
|
<A
|
||||||
|
href="/"
|
||||||
|
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors"
|
||||||
|
>
|
||||||
|
← back to Gaze
|
||||||
|
</A>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,30 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { createSignal, For } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
import { downloadAsset } from "~/lib/download-asset";
|
import { downloadAsset } from "~/lib/download-asset";
|
||||||
|
|
||||||
const GAZE_APP_STORE_URL =
|
const GAZE_APP_STORE_URL = "https://apps.apple.com/us/app/gaze/id6757759498";
|
||||||
"https://apps.apple.com/us/app/gaze/id6757759498";
|
const GAZE_MIN_MACOS = "14.6";
|
||||||
const GAZE_MIN_MACOS = "13.0";
|
|
||||||
|
|
||||||
const FEATURES = [
|
const FEATURES = [
|
||||||
{
|
{
|
||||||
title: "Take regular breaks",
|
title: "Blink reminders",
|
||||||
body: "Gentle, dismissable reminders nudge you to step away from the screen so you can rest and reset."
|
body: "Subtle prompts help you remember to blink more often, reducing dry-eye strain during long sessions."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Eye health reminders",
|
title: "20-20-20 eye breaks",
|
||||||
body: "Follow the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds."
|
body: "Follow the 20-20-20 rule — every 20 minutes, look at something 20 feet away for 20 seconds."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Posture reminders",
|
title: "Posture check-ins",
|
||||||
body: "Periodic check-ins help you catch slouching before it becomes a habit."
|
body: "Periodic reminders help you catch slouching before it becomes a habit."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Customizable intervals",
|
||||||
|
body: "Set the reminder cadence that fits your workflow, from gentle nudges to strict schedules."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Lives in your menu bar",
|
title: "Lives in your menu bar",
|
||||||
@@ -53,19 +57,21 @@ export default function GazeLanding() {
|
|||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Eye & posture health reminder for macOS"
|
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."
|
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
|
||||||
ogImage="/look-away.png"
|
ogImage="/look-away.png"
|
||||||
ogTitle="Gaze — Eye and posture health reminder for macOS"
|
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."
|
ogDescription="A macOS menu bar app that helps you remember to blink, take breaks, and sit up straight."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
||||||
<div class="relative flex min-h-screen flex-col">
|
<div class="relative flex min-h-screen flex-col">
|
||||||
<div class="fixed inset-0 z-0 overflow-hidden brightness-75">
|
<div class="fixed inset-0 z-0 overflow-hidden brightness-75">
|
||||||
<img
|
<img
|
||||||
src="/look-away.png"
|
src="/look-away.png"
|
||||||
alt="Look away — Gaze hero background"
|
alt="Look away — Gaze hero background"
|
||||||
class="h-full w-full select-none object-cover"
|
class="h-full w-full object-cover select-none"
|
||||||
style={{ "pointer-events": "none" }}
|
style={{ "pointer-events": "none" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,17 +126,17 @@ export default function GazeLanding() {
|
|||||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||||
Small reminders, healthier habits
|
Small reminders, healthier habits
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{FEATURES.map((feature) => (
|
<For each={FEATURES}>
|
||||||
<div
|
{(feature) => (
|
||||||
class="border-overlay0 bg-surface0 rounded-lg border p-6"
|
<div class="border-overlay0 bg-surface0 rounded-lg border p-6">
|
||||||
>
|
|
||||||
<h3 class="text-text mb-2 text-xl font-semibold">
|
<h3 class="text-text mb-2 text-xl font-semibold">
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
|
<p class="text-subtext0 leading-relaxed">{feature.body}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function GazePrivacyPolicy() {
|
export default function GazePrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
@@ -23,9 +24,10 @@ export default function GazePrivacyPolicy() {
|
|||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Gaze, a macOS eye health reminder app."
|
description="Privacy policy for Gaze, a macOS eye health reminder app."
|
||||||
/>
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
<div class="py-4 text-xl">Gaze's Privacy Policy</div>
|
<div class="py-4 text-xl">Gaze's Privacy Policy</div>
|
||||||
<div class="py-2">Last Updated: February 9, 2026</div>
|
<div class="py-2">Last Updated: July 23, 2026</div>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
Welcome to Gaze ('We', 'Us', 'Our').
|
Welcome to Gaze ('We', 'Us', 'Our').
|
||||||
Your privacy is important to us. This privacy policy will help you
|
Your privacy is important to us. This privacy policy will help you
|
||||||
|
|||||||
@@ -1,8 +1,55 @@
|
|||||||
|
import { Switch, Match, type JSX } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||||
import { Typewriter } from "~/components/Typewriter";
|
import { Typewriter } from "~/components/Typewriter";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import NessaLanding from "./nessa";
|
||||||
|
import LineageLanding from "./lineage";
|
||||||
|
import GazeLanding from "./gaze";
|
||||||
|
import InputHaloLanding from "./inputhalo";
|
||||||
|
|
||||||
export default function Home() {
|
/**
|
||||||
|
* Root route handler.
|
||||||
|
*
|
||||||
|
* SolidStart's file router cannot match on host, so `vercel.json` host
|
||||||
|
* rewrites (`nessa.freno.me/(.*) → /nessa/$1`) map each subdomain onto its
|
||||||
|
* `src/routes/<prefix>/*` file route in production. The vinxi dev server
|
||||||
|
* ignores `vercel.json`, however — so in dev a subdomain root (`nessa.localhost/`)
|
||||||
|
* would otherwise fall through to this `index.tsx` and render Mike's personal
|
||||||
|
* page with only the sidebar changing.
|
||||||
|
*
|
||||||
|
* The AGENTS.md-sanctioned remedy is to branch on the resolved `Site` (via
|
||||||
|
* the `useSite()` accessor from `src/lib/site-context.ts`, NOT raw host
|
||||||
|
* sifting) and render the matching subdomain landing component here. This
|
||||||
|
* makes the subdomain roots render their unique landing pages in dev *and*
|
||||||
|
* acts as a production safety net should a Vercel rewrite ever fail to fire.
|
||||||
|
* In production the vercel rewrite still wins — `nessa.freno.me/` →
|
||||||
|
* `/nessa/index.tsx` directly — so the two paths render the same component:
|
||||||
|
* `NessaLanding`.
|
||||||
|
*/
|
||||||
|
export default function Home(): JSX.Element {
|
||||||
|
const site = useSite();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch fallback={<MainHome />}>
|
||||||
|
<Match when={site().id === "nessa"}>
|
||||||
|
<NessaLanding />
|
||||||
|
</Match>
|
||||||
|
<Match when={site().id === "lineage"}>
|
||||||
|
<LineageLanding />
|
||||||
|
</Match>
|
||||||
|
<Match when={site().id === "gaze"}>
|
||||||
|
<GazeLanding />
|
||||||
|
</Match>
|
||||||
|
<Match when={site().id === "inputhalo"}>
|
||||||
|
<InputHaloLanding />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The freno.me personal site landing page (default/main host only). */
|
||||||
|
function MainHome(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InputHalo contact page (`inputhalo.freno.me/contact`).
|
* InputHalo contact page (`inputhalo.freno.me/contact`).
|
||||||
@@ -16,5 +17,10 @@ import { ContactForm } from "~/components/ContactForm";
|
|||||||
* `[InputHalo] Contact Request`.
|
* `[InputHalo] Contact Request`.
|
||||||
*/
|
*/
|
||||||
export default function InputHaloContactPage() {
|
export default function InputHaloContactPage() {
|
||||||
return <ContactForm />;
|
return (
|
||||||
|
<>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<ContactForm />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/routes/inputhalo/downloads.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* InputHalo per-subdomain downloads page — `inputhalo.freno.me/downloads`.
|
||||||
|
*
|
||||||
|
* Public browser path `/downloads`; vercel.json host rewrites serve this
|
||||||
|
* from the internal `/inputhalo/*` route prefix while keeping the URL clean.
|
||||||
|
*
|
||||||
|
* Mirrors the download surface already exposed on the InputHalo landing page:
|
||||||
|
* - Direct signed-S3 DMG download via `downloads.getDownloadUrl({ asset_name: "inputhalo" })`.
|
||||||
|
* - macOS App Store listing link (paid variant — currently a placeholder URL).
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
|
import Button from "~/components/ui/Button";
|
||||||
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { api } from "~/lib/api";
|
||||||
|
import {
|
||||||
|
INPUTHALO_APP_STORE_URL,
|
||||||
|
INPUTHALO_ICON_DARK,
|
||||||
|
INPUTHALO_ICON_DEFAULT,
|
||||||
|
INPUTHALO_MIN_SYSTEM_VERSION,
|
||||||
|
performInputHaloDownload
|
||||||
|
} from "~/routes/inputhalo/download";
|
||||||
|
|
||||||
|
export default function InputHaloDownloadsPage() {
|
||||||
|
const site = useSite();
|
||||||
|
const { isDark } = useDarkMode();
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
|
||||||
|
const iconSrc = () =>
|
||||||
|
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
if (loading()) return;
|
||||||
|
setLoading(true);
|
||||||
|
performInputHaloDownload(
|
||||||
|
(input) => api.downloads.getDownloadUrl.query(input),
|
||||||
|
(url) => {
|
||||||
|
window.location.href = url;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
alert("Failed to initiate download. Please try again.");
|
||||||
|
}
|
||||||
|
).finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Download InputHalo"
|
||||||
|
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
|
<main class="bg-base relative min-h-screen w-full overflow-hidden px-4 pb-16">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-0 z-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(50% 40% at 50% 0%, ${site().brandColor}16 0%, transparent 70%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="relative z-10 mx-auto flex max-w-2xl flex-col items-center pt-[12vh] text-center">
|
||||||
|
<img
|
||||||
|
src={iconSrc()}
|
||||||
|
alt="InputHalo app icon"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
class="mb-6 h-28 w-28 rounded-[22%] object-cover shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 class="mb-2 text-4xl font-bold tracking-tight">
|
||||||
|
Download InputHalo
|
||||||
|
</h1>
|
||||||
|
<p class="text-text/70 mb-10 max-w-md text-lg">
|
||||||
|
Show every keystroke, click, and scroll on macOS.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col items-center justify-center gap-8 sm:flex-row">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<span class="text-subtext0 text-sm tracking-wider uppercase">
|
||||||
|
Direct download
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
loading={loading()}
|
||||||
|
onClick={download}
|
||||||
|
>
|
||||||
|
inputhalo.dmg
|
||||||
|
</Button>
|
||||||
|
<span class="text-subtext1 max-w-[240px] text-center text-xs">
|
||||||
|
macOS {INPUTHALO_MIN_SYSTEM_VERSION}+ · auto-updates via Sparkle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-surface0/40 hidden h-24 w-px sm:block" />
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<span class="text-subtext0 text-sm tracking-wider uppercase">
|
||||||
|
App Store
|
||||||
|
</span>
|
||||||
|
<A
|
||||||
|
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
||||||
|
href={INPUTHALO_APP_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadOnAppStoreDark size={50} />
|
||||||
|
</A>
|
||||||
|
<span class="text-subtext1 max-w-[240px] text-center text-xs">
|
||||||
|
Paid App Store variant coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-subtext0 mt-16 text-center text-sm">
|
||||||
|
<A
|
||||||
|
href="/"
|
||||||
|
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors"
|
||||||
|
>
|
||||||
|
← back to InputHalo
|
||||||
|
</A>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* InputHalo landing page — net-new marketing page for inputhalo.freno.me
|
* InputHalo landing page — net-new marketing page for inputhalo.freno.me.
|
||||||
* (task 06).
|
|
||||||
*
|
*
|
||||||
* Routes:
|
* InputHalo is a polished macOS menu bar app that visualizes mouse and
|
||||||
* - vercel.json rewrites `inputhalo.freno.me/(.*)` → `/inputhalo/$1`, so
|
* keyboard input on screen: keyboard indicators, cursor halos, click ripples,
|
||||||
* this `src/routes/inputhalo/index.tsx` serves the subdomain root.
|
* scroll indicators, and sensitive-input detection. It is built with SwiftUI
|
||||||
|
* and distributed via direct DMG (Sparkle auto-updates) with a paid App Store
|
||||||
|
* variant planned.
|
||||||
*
|
*
|
||||||
* Sections:
|
* The download orchestration stays in the testable pure `./download.ts` module;
|
||||||
* - Hero: app icon (dark/light variant via `useDarkMode`), title, tagline
|
* this component is a thin wrapper around it plus the landing-page UX.
|
||||||
* - Feature highlights (real-time tracking, menu bar, customizable, native)
|
|
||||||
* - Download: inline macOS DMG button (tRPC → signed S3 URL) + App Store link
|
|
||||||
*
|
|
||||||
* The download orchestration lives in the pure, unit-tested
|
|
||||||
* `./download.ts` helper — this component is a thin wrapper that supplies the
|
|
||||||
* real tRPC client + `window.location.href` redirect and the loading/error
|
|
||||||
* UX. The pattern mirrors `downloads.tsx` and the site-aware PageHead /
|
|
||||||
* nav-config split.
|
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { For, createSignal } from "solid-js";
|
||||||
import Button from "~/components/ui/Button";
|
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
|
||||||
import { glitchText } from "~/lib/client-utils";
|
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import Button from "~/components/ui/Button";
|
||||||
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import {
|
import {
|
||||||
INPUTHALO_APP_STORE_URL,
|
INPUTHALO_APP_STORE_URL,
|
||||||
@@ -33,9 +27,55 @@ import {
|
|||||||
performInputHaloDownload
|
performInputHaloDownload
|
||||||
} from "./download";
|
} from "./download";
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
title: "Keyboard indicator overlay",
|
||||||
|
body: "Display the keys you press in a clean, configurable on-screen overlay — perfect for demos and tutorials."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Mouse halo & click ripples",
|
||||||
|
body: "Add a subtle halo around your cursor and visual click feedback so viewers never lose track of your pointer."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Scroll indicators",
|
||||||
|
body: "Show scroll direction and intensity in real time, useful for screencasts and accessibility demos."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Sensitive input detection",
|
||||||
|
body: "InputHalo automatically hides visual feedback when you type into password or secure fields."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fully customizable",
|
||||||
|
body: "Choose colors, sizes, animations, position, and behavior to match your setup and brand."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Native macOS menu bar app",
|
||||||
|
body: "Built with SwiftUI, runs quietly in the menu bar, and uses system accessibility APIs responsibly."
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const USE_CASES = [
|
||||||
|
{
|
||||||
|
title: "Streamers & creators",
|
||||||
|
body: "Let your audience see exactly what you're clicking and typing without cluttering your scene."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Presenters & educators",
|
||||||
|
body: "Make keyboard shortcuts and cursor movement crystal clear during demos and recordings."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Developers",
|
||||||
|
body: "Show input interactions in bug reports, design reviews, and pair-programming sessions."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Accessibility",
|
||||||
|
body: "Visualize inputs to make workflows easier to follow for users who benefit from clear feedback."
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default function InputHaloLanding() {
|
export default function InputHaloLanding() {
|
||||||
|
const site = useSite();
|
||||||
const { isDark } = useDarkMode();
|
const { isDark } = useDarkMode();
|
||||||
const [titleText, setTitleText] = createSignal("InputHalo");
|
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
|
||||||
const download = () => {
|
const download = () => {
|
||||||
@@ -52,111 +92,62 @@ export default function InputHaloLanding() {
|
|||||||
).finally(() => setLoading(false));
|
).finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
const iconSrc = () =>
|
||||||
const interval = glitchText(titleText(), setTitleText);
|
isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT;
|
||||||
onCleanup(() => clearInterval(interval));
|
const brandColor = () => site().brandColor;
|
||||||
});
|
|
||||||
|
|
||||||
const iconSrc = () => (isDark() ? INPUTHALO_ICON_DARK : INPUTHALO_ICON_DEFAULT);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="InputHalo"
|
title="InputHalo"
|
||||||
description="Input visualization for mouse and keyboard — a macOS menu bar app for real-time input tracking and customization."
|
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
|
<SubdomainHeader />
|
||||||
{/* Subtle scanline effect */}
|
|
||||||
<div class="pointer-events-none absolute inset-0 opacity-5">
|
<main
|
||||||
|
class="relative min-h-screen w-full overflow-x-hidden"
|
||||||
|
style={{ "--brand-color": brandColor() }}
|
||||||
|
>
|
||||||
|
{/* Soft pink halos in the background */}
|
||||||
<div
|
<div
|
||||||
class="h-full w-full"
|
class="pointer-events-none fixed inset-0 z-0"
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
"background-image":
|
background: isDark()
|
||||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.2) 2px, rgba(0,0,0,0.2) 4px)"
|
? `radial-gradient(60% 50% at 80% 0%, ${brandColor()}20 0%, transparent 70%), radial-gradient(50% 40% at 20% 100%, ${brandColor()}16 0%, transparent 70%)`
|
||||||
|
: `radial-gradient(60% 50% at 80% 0%, ${brandColor()}16 0%, transparent 70%), radial-gradient(50% 40% at 20% 100%, ${brandColor()}12 0%, transparent 70%)`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 mx-auto max-w-5xl">
|
{/* ─── Hero ───────────────────────────────────────────────── */}
|
||||||
{/* Hero Section */}
|
<section class="relative z-10 flex flex-col items-center px-4 pt-24 pb-16 text-center md:pt-32">
|
||||||
<div class="mb-16 text-center">
|
<div
|
||||||
{/* App Icon — dark/light variant matches the Gaze pattern */}
|
class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32"
|
||||||
<div class="mb-8 flex justify-center">
|
style={{ color: brandColor() }}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={iconSrc()}
|
src={iconSrc()}
|
||||||
alt="InputHalo app icon"
|
alt="InputHalo app icon"
|
||||||
class="h-32 w-32 rounded-2xl shadow-lg transition-transform duration-200 hover:scale-105"
|
width={128}
|
||||||
|
height={128}
|
||||||
|
class="h-full w-full rounded-[1.75rem] object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
<h1 class="text-5xl font-bold tracking-tight md:text-7xl">
|
||||||
<h1 class="text-text mb-4 font-mono text-4xl md:text-5xl">
|
InputHalo
|
||||||
<span class="text-red">{">"}</span> {titleText()}
|
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
|
||||||
{/* Tagline (from appcast-template.xml) */}
|
Show every keystroke, click, and scroll.
|
||||||
<p class="text-subtext0 mb-2 text-xl italic">
|
</p>
|
||||||
Input visualization for mouse and keyboard
|
<p class="text-text/70 mt-3 max-w-xl text-base md:text-lg">
|
||||||
|
A polished macOS menu bar app for input visualization — built for
|
||||||
|
streamers, presenters, developers, and anyone who wants their inputs
|
||||||
|
seen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Platform note */}
|
<div class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
|
||||||
<span class="text-subtext1 font-mono text-sm">
|
|
||||||
macOS menu bar app · minimum macOS {INPUTHALO_MIN_SYSTEM_VERSION}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature Highlights */}
|
|
||||||
<div class="mb-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
|
|
||||||
<h3 class="text-text mb-2 font-mono text-lg">
|
|
||||||
<span class="text-red">{">"}</span> Real-time Tracking
|
|
||||||
</h3>
|
|
||||||
<p class="text-subtext0">
|
|
||||||
Visualize every mouse click and keyboard press as it happens
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
|
|
||||||
<h3 class="text-text mb-2 font-mono text-lg">
|
|
||||||
<span class="text-red">{">"}</span> Menu Bar App
|
|
||||||
</h3>
|
|
||||||
<p class="text-subtext0">
|
|
||||||
Lightweight presence in your menu bar — access settings anytime
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
|
|
||||||
<h3 class="text-text mb-2 font-mono text-lg">
|
|
||||||
<span class="text-red">{">"}</span> Customizable
|
|
||||||
</h3>
|
|
||||||
<p class="text-subtext0">
|
|
||||||
Personalize colors, styles, and behavior to match your workflow
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-overlay0 rounded-lg border p-6 transition-transform duration-200 hover:scale-105">
|
|
||||||
<h3 class="text-text mb-2 font-mono text-lg">
|
|
||||||
<span class="text-red">{">"}</span> Native macOS
|
|
||||||
</h3>
|
|
||||||
<p class="text-subtext0">
|
|
||||||
Built with Swift and SwiftUI for optimal performance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Download Section */}
|
|
||||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
|
||||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
|
||||||
<span class="text-red">{">"}</span> Download
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
|
|
||||||
{/* DMG Download (tRPC → signed S3 URL) */}
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<span class="text-subtext0 font-mono text-sm">
|
|
||||||
platform: macOS ({INPUTHALO_MIN_SYSTEM_VERSION}+)
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="download"
|
variant="download"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -165,29 +156,132 @@ export default function InputHaloLanding() {
|
|||||||
>
|
>
|
||||||
download.dmg
|
download.dmg
|
||||||
</Button>
|
</Button>
|
||||||
<span class="text-subtext1 text-xs">
|
|
||||||
# auto-updates via Sparkle
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App Store (paid — coming soon) */}
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<span class="text-subtext0 font-mono text-sm">
|
|
||||||
variant: paid (coming soon)
|
|
||||||
</span>
|
|
||||||
<A
|
<A
|
||||||
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
||||||
href={INPUTHALO_APP_STORE_URL}
|
href={INPUTHALO_APP_STORE_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<DownloadOnAppStore size={50} />
|
<DownloadOnAppStoreDark size={50} />
|
||||||
</A>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-text/50 mt-3 text-sm">
|
||||||
|
macOS {INPUTHALO_MIN_SYSTEM_VERSION}+ · auto-updates via Sparkle
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Feature highlights ─────────────────────────────────── */}
|
||||||
|
<section class="bg-surface0/30 relative z-10 px-4 py-20">
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold md:text-4xl">
|
||||||
|
Made for showing your inputs
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<For each={FEATURES}>
|
||||||
|
{(feature) => (
|
||||||
|
<div class="border-surface0 bg-base/80 flex flex-col gap-3 rounded-2xl border-2 p-6 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
class="mb-1 flex h-8 w-8 items-center justify-center rounded-full text-white"
|
||||||
|
style={{ background: brandColor() }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold">{feature.title}</h3>
|
||||||
|
<p class="text-text/80 text-sm leading-relaxed">
|
||||||
|
{feature.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Use cases ──────────────────────────────────────────── */}
|
||||||
|
<section class="relative z-10 px-4 py-20">
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold md:text-4xl">
|
||||||
|
Who it's for
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<For each={USE_CASES}>
|
||||||
|
{(use) => (
|
||||||
|
<div class="border-surface0 hover:bg-surface0/30 flex flex-col gap-2 rounded-2xl border-2 p-6 transition-colors">
|
||||||
|
<h3
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
style={{ color: brandColor() }}
|
||||||
|
>
|
||||||
|
{use.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-text/80 text-sm leading-relaxed">
|
||||||
|
{use.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Download CTA ───────────────────────────────────────── */}
|
||||||
|
<section class="bg-surface0/30 relative z-10 px-4 py-20">
|
||||||
|
<div class="mx-auto max-w-4xl text-center">
|
||||||
|
<h2 class="text-text mb-4 text-3xl font-bold">
|
||||||
|
Ready to visualize your inputs?
|
||||||
|
</h2>
|
||||||
|
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
|
||||||
|
Download the latest signed macOS build directly, or grab the App
|
||||||
|
Store version once it launches.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
variant="download"
|
||||||
|
size="lg"
|
||||||
|
loading={loading()}
|
||||||
|
onClick={download}
|
||||||
|
>
|
||||||
|
download.dmg
|
||||||
|
</Button>
|
||||||
|
<A
|
||||||
|
class="my-auto transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
||||||
|
href={INPUTHALO_APP_STORE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<DownloadOnAppStoreDark size={50} />
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext1 mt-6 text-xs">
|
||||||
|
Requires macOS {INPUTHALO_MIN_SYSTEM_VERSION} or later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Footer ─────────────────────────────────────────────── */}
|
||||||
|
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
|
||||||
|
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
|
||||||
|
<span>{site().displayName}</span>
|
||||||
|
<A
|
||||||
|
href="https://freno.me"
|
||||||
|
class="hover:text-text underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
freno.me
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function InputHaloPrivacyPolicy() {
|
export default function InputHaloPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
@@ -32,15 +33,15 @@ export default function InputHaloPrivacyPolicy() {
|
|||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
|
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
|
||||||
/>
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
<div class="py-4 text-xl">InputHalo's Privacy Policy</div>
|
<div class="py-4 text-xl">InputHalo's Privacy Policy</div>
|
||||||
<div class="py-2">Last Updated: February 9, 2026</div>
|
<div class="py-2">Last Updated: July 23, 2026</div>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
Welcome to InputHalo ('We', 'Us',
|
Welcome to InputHalo ('We', 'Us',
|
||||||
'Our'). Your privacy is important to us. This privacy
|
'Our'). Your privacy is important to us. This privacy policy
|
||||||
policy will help you understand our policies and procedures related
|
will help you understand our policies and procedures related to the
|
||||||
to the collection, use, and storage of personal information from our
|
collection, use, and storage of personal information from our users.
|
||||||
users.
|
|
||||||
</div>
|
</div>
|
||||||
<ol>
|
<ol>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
@@ -52,17 +53,17 @@ export default function InputHaloPrivacyPolicy() {
|
|||||||
<div class="-ml-6">(a) Collection of Personal Data:</div>{" "}
|
<div class="-ml-6">(a) Collection of Personal Data:</div>{" "}
|
||||||
InputHalo is designed with privacy as a core principle. We
|
InputHalo is designed with privacy as a core principle. We
|
||||||
currently do not collect, store, or share any personal
|
currently do not collect, store, or share any personal
|
||||||
information from our users. The app runs entirely on your
|
information from our users. The app runs entirely on your device
|
||||||
device as a menu bar utility and does not require any account
|
as a menu bar utility and does not require any account creation
|
||||||
creation or data transmission to external servers.
|
or data transmission to external servers.
|
||||||
</div>
|
</div>
|
||||||
<div class="pb-2">
|
<div class="pb-2">
|
||||||
<div class="-ml-6">(b) Local-Only Settings:</div> Any
|
<div class="-ml-6">(b) Local-Only Settings:</div> Any
|
||||||
preferences, configuration, or cached state InputHalo maintains
|
preferences, configuration, or cached state InputHalo maintains
|
||||||
is stored locally on your device using standard macOS
|
is stored locally on your device using standard macOS mechanisms
|
||||||
mechanisms (such as the user defaults system). This data never
|
(such as the user defaults system). This data never leaves your
|
||||||
leaves your device and is not transmitted to us or to any
|
device and is not transmitted to us or to any third-party
|
||||||
third-party service.
|
service.
|
||||||
</div>
|
</div>
|
||||||
<div class="pb-2">
|
<div class="pb-2">
|
||||||
<div class="-ml-6">(c) Future Data Collection:</div> We may in
|
<div class="-ml-6">(c) Future Data Collection:</div> We may in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
import { LineageContactQuestions } from "~/routes/contact";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Life and Lineage contact page (`lineage.freno.me/contact`).
|
* Life and Lineage contact page (`lineage.freno.me/contact`).
|
||||||
@@ -9,9 +9,9 @@ import { LineageContactQuestions } from "~/routes/contact";
|
|||||||
* — is derived from `useSite()` inside the component via
|
* — is derived from `useSite()` inside the component via
|
||||||
* `CONTACT_CONTEXT.lineage`.
|
* `CONTACT_CONTEXT.lineage`.
|
||||||
*
|
*
|
||||||
* Renders the Life-and-Lineage FAQ accordion (re-imported from the main-site
|
* Subdomain contact pages intentionally render only the shared contact form —
|
||||||
* contact route so the canonical definition lives in one place) above the
|
* no Life-and-Lineage FAQ accordion and no main-site disclaimer subline
|
||||||
* form — the lineage subdomain is the natural home for product support Q&A.
|
* (those remain exclusive to the main `freno.me/contact` page).
|
||||||
*
|
*
|
||||||
* vercel.json rewrites `lineage.freno.me/*` → the internal `/lineage/*` route
|
* vercel.json rewrites `lineage.freno.me/*` → the internal `/lineage/*` route
|
||||||
* prefix; the browser URL stays `lineage.freno.me/contact`.
|
* prefix; the browser URL stays `lineage.freno.me/contact`.
|
||||||
@@ -22,8 +22,9 @@ import { LineageContactQuestions } from "~/routes/contact";
|
|||||||
*/
|
*/
|
||||||
export default function LineageContactPage() {
|
export default function LineageContactPage() {
|
||||||
return (
|
return (
|
||||||
<ContactForm>
|
<>
|
||||||
<LineageContactQuestions />
|
<SubdomainHeader />
|
||||||
</ContactForm>
|
<ContactForm />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
* the form posts to the correct tRPC mutation (Lineage-branded email).
|
* the form posts to the correct tRPC mutation (Lineage-branded email).
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DeletionForm from "~/components/DeletionForm";
|
import DeletionForm from "~/components/DeletionForm";
|
||||||
import {
|
import {
|
||||||
DELETION_PRODUCT_KEY,
|
DELETION_PRODUCT_KEY,
|
||||||
@@ -45,6 +46,7 @@ export default function LineageDeletionPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
|
<SubdomainHeader />
|
||||||
<div class="pt-20">
|
<div class="pt-20">
|
||||||
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
||||||
<div class="text-text w-full justify-center">
|
<div class="text-text w-full justify-center">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
import { glitchText } from "~/lib/client-utils";
|
import { glitchText } from "~/lib/client-utils";
|
||||||
@@ -71,10 +72,9 @@ export default function LineageDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
title={PAGE_META.title}
|
|
||||||
description={PAGE_META.description}
|
<SubdomainHeader />
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
|
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
|
||||||
{/* Subtle scanline effect — consistent with the unified downloads page. */}
|
{/* Subtle scanline effect — consistent with the unified downloads page. */}
|
||||||
@@ -131,7 +131,7 @@ export default function LineageDownloadsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary CTA → landing page */}
|
{/* Secondary CTA → landing page */}
|
||||||
<p class="mt-12 text-center text-sm text-subtext0">
|
<p class="text-subtext0 mt-12 text-center text-sm">
|
||||||
<A
|
<A
|
||||||
href={LINEAGE_HOME_HREF}
|
href={LINEAGE_HOME_HREF}
|
||||||
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
|||||||
@@ -1,46 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Life and Lineage — lineage subdomain landing page (task 08).
|
* Life and Lineage — lineage subdomain landing page.
|
||||||
*
|
*
|
||||||
* Migrates the legacy `src/routes/marketing/life-and-lineage.tsx` page to the
|
* This intentionally preserves the original marketing-page structure: a single
|
||||||
* `lineage.freno.me` root (vercel.json rewrites `lineage.freno.me/*` →
|
* full-viewport `SimpleParallax` Cave background with the app icon, title,
|
||||||
* `/lineage/*`), and enriches it with feature highlights + a game-art strip.
|
* tagline, and store badges centered on screen. No sidebars, no scrollable
|
||||||
*
|
* feature sections, and no extra content — the parallax effect depends on the
|
||||||
* Site-awareness:
|
* page being exactly one viewport tall. The persistent `SubdomainHeader`
|
||||||
* - `<PageHead>` reads `useSite()` → the lineage `titleSuffix`
|
* sits sticky above the parallax backdrop so navigation is reachable on every
|
||||||
* (` | Life and Lineage`) + canonical `https://lineage.freno.me/` are
|
* subdomain page.
|
||||||
* derived automatically (task 02); we pass only the base title here.
|
|
||||||
* - The Google Play badge links to the **public browser path** `/downloads`
|
|
||||||
* (subdomain-relative), which vercel rewrites to `/lineage/downloads`
|
|
||||||
* (task 11). The App Store link is an absolute external URL, unchanged.
|
|
||||||
* - `SimpleParallax` reuses the existing Cave parallax background — a
|
|
||||||
* fitting "dark fantasy" ambience — and continues to work under the
|
|
||||||
* subdomain because assets are served from the shared `/public` root.
|
|
||||||
*
|
|
||||||
* Auth: this page performs NO auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`)
|
|
||||||
* is for the mobile app's API calls, not the web landing page.
|
|
||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import SimpleParallax from "~/components/SimpleParallax";
|
import SimpleParallax from "~/components/SimpleParallax";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import { For } from "solid-js";
|
|
||||||
import {
|
import {
|
||||||
APP_STORE_URL,
|
APP_STORE_URL,
|
||||||
DOWNLOADS_HREF,
|
DOWNLOADS_HREF,
|
||||||
APP_ICON_SRC,
|
APP_ICON_SRC,
|
||||||
GOOGLE_PLAY_BADGE_SRC,
|
GOOGLE_PLAY_BADGE_SRC,
|
||||||
SCREENSHOT_ASSETS,
|
PAGE_META
|
||||||
PAGE_META,
|
|
||||||
FEATURES
|
|
||||||
} from "~/routes/lineage/landing-content";
|
} from "~/routes/lineage/landing-content";
|
||||||
|
|
||||||
export default function LineageLandingPage() {
|
export default function LineageLandingPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
|
<SubdomainHeader />
|
||||||
<SimpleParallax>
|
<SimpleParallax>
|
||||||
<div class="flex h-full flex-col items-center justify-center px-4 text-white">
|
<div class="flex h-full flex-col items-center justify-center px-4 text-white">
|
||||||
{/* Hero */}
|
<div>
|
||||||
<img
|
<img
|
||||||
src={APP_ICON_SRC}
|
src={APP_ICON_SRC}
|
||||||
alt="Life and Lineage App Icon"
|
alt="Life and Lineage App Icon"
|
||||||
@@ -48,13 +37,13 @@ export default function LineageLandingPage() {
|
|||||||
width={128}
|
width={128}
|
||||||
class="object-cover object-center"
|
class="object-cover object-center"
|
||||||
/>
|
/>
|
||||||
<h1 class="mb-4 mt-4 text-center text-5xl font-bold">
|
</div>
|
||||||
|
<h1 class="mt-4 mb-4 text-center text-5xl font-bold">
|
||||||
Life and Lineage
|
Life and Lineage
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mb-8 text-xl">A dark fantasy adventure</p>
|
<p class="text-xl">A dark fantasy adventure</p>
|
||||||
|
|
||||||
{/* Store badges — App Store (external) + Google Play → /downloads */}
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-4">
|
||||||
<div class="flex flex-wrap items-center justify-center gap-4">
|
|
||||||
<a
|
<a
|
||||||
class="my-auto transition-all duration-200 ease-out active:scale-95"
|
class="my-auto transition-all duration-200 ease-out active:scale-95"
|
||||||
href={APP_STORE_URL}
|
href={APP_STORE_URL}
|
||||||
@@ -75,68 +64,6 @@ export default function LineageLandingPage() {
|
|||||||
/>
|
/>
|
||||||
</A>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature highlights */}
|
|
||||||
<section class="mt-16 w-full max-w-4xl">
|
|
||||||
<h2 class="mb-6 text-center text-2xl font-semibold">
|
|
||||||
Why players return
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<For each={FEATURES}>
|
|
||||||
{(feature) => (
|
|
||||||
<div class="bg-base/70 rounded-lg p-4 backdrop-blur-sm">
|
|
||||||
<h3 class="mb-1 text-lg font-bold">{feature.title}</h3>
|
|
||||||
<p class="text-sm text-subtext0">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Game art / screenshots strip */}
|
|
||||||
<section class="mt-16 w-full max-w-4xl">
|
|
||||||
<h2 class="mb-6 text-center text-2xl font-semibold">
|
|
||||||
glimpses of the world
|
|
||||||
</h2>
|
|
||||||
<div class="flex flex-col items-center gap-6">
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-6">
|
|
||||||
<img
|
|
||||||
src={SCREENSHOT_ASSETS.home}
|
|
||||||
alt="Life and Lineage home"
|
|
||||||
class="h-auto w-full max-w-sm rounded-lg shadow-lg"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={SCREENSHOT_ASSETS.shops}
|
|
||||||
alt="Life and Lineage shops"
|
|
||||||
class="h-auto w-full max-w-sm rounded-lg shadow-lg"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<video
|
|
||||||
src={SCREENSHOT_ASSETS.preview}
|
|
||||||
class="w-full max-w-2xl rounded-lg shadow-lg"
|
|
||||||
controls
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
preload="metadata"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Secondary CTA → per-subdomain downloads page */}
|
|
||||||
<p class="mt-16 text-sm text-subtext0">
|
|
||||||
Prefer a direct download?{" "}
|
|
||||||
<A
|
|
||||||
href={DOWNLOADS_HREF}
|
|
||||||
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
|
||||||
>
|
|
||||||
Visit the downloads page
|
|
||||||
</A>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</SimpleParallax>
|
</SimpleParallax>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function LineagePrivacyPolicy() {
|
export default function LineagePrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
@@ -25,6 +26,7 @@ export default function LineagePrivacyPolicy() {
|
|||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
|
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
|
||||||
/>
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
<div class="py-4 text-xl">Life and Lineage's Privacy Policy</div>
|
<div class="py-4 text-xl">Life and Lineage's Privacy Policy</div>
|
||||||
<div class="py-2">Last Updated: October 22, 2024</div>
|
<div class="py-2">Last Updated: October 22, 2024</div>
|
||||||
@@ -52,10 +54,7 @@ export default function LineagePrivacyPolicy() {
|
|||||||
<div class="pb-2">
|
<div class="pb-2">
|
||||||
<div class="-ml-6">(b) Data Removal:</div> Users can request the
|
<div class="-ml-6">(b) Data Removal:</div> Users can request the
|
||||||
removal of all information related to them by visiting{" "}
|
removal of all information related to them by visiting{" "}
|
||||||
<A
|
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||||
href="/deletion"
|
|
||||||
class="text-blue hover-underline-animation"
|
|
||||||
>
|
|
||||||
this page
|
this page
|
||||||
</A>{" "}
|
</A>{" "}
|
||||||
and filling out the provided form.
|
and filling out the provided form.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nessa contact page (`nessa.freno.me/contact`).
|
* Nessa contact page (`nessa.freno.me/contact`).
|
||||||
@@ -17,5 +18,10 @@ import { ContactForm } from "~/components/ContactForm";
|
|||||||
* `[Nessa] Contact Request`.
|
* `[Nessa] Contact Request`.
|
||||||
*/
|
*/
|
||||||
export default function NessaContactPage() {
|
export default function NessaContactPage() {
|
||||||
return <ContactForm />;
|
return (
|
||||||
|
<>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<ContactForm />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
193
src/routes/nessa/content.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Pure content + metadata for the Nessa subdomain landing page.
|
||||||
|
*
|
||||||
|
* Sourced from the real product positioning doc in
|
||||||
|
* `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
|
||||||
|
* and the profitability plan (`nessa_profitability_plan_2026-03-09.md`).
|
||||||
|
*
|
||||||
|
* Intentionally imports NOTHING from solid-js / @solidjs /
|
||||||
|
* @solidjs/meta so it can be unit-tested with `bun:test` and referenced from
|
||||||
|
* the route render layer without dragging the router into pure tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TAGLINE = "The fitness app that puts you first." as const;
|
||||||
|
export const SUBTITLE =
|
||||||
|
"Tired of Strava's paywalls and price hikes? Track, train, and connect — without the paywall." as const;
|
||||||
|
|
||||||
|
export const ICON_DEFAULT =
|
||||||
|
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
||||||
|
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;
|
||||||
|
|
||||||
|
export const SCREENSHOTS = {
|
||||||
|
home: {
|
||||||
|
src: "/Nessa Exports/01-home-tab.png",
|
||||||
|
alt: "Nessa home dashboard with activity metrics"
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
src: "/Nessa Exports/40-segment-list.png",
|
||||||
|
alt: "Segment leaderboards and KOM/QOM contenders"
|
||||||
|
},
|
||||||
|
clubs: {
|
||||||
|
src: "/Nessa Exports/09-clubs-list.png",
|
||||||
|
alt: "Clubs and social feed"
|
||||||
|
},
|
||||||
|
workoutSummary: {
|
||||||
|
src: "/Nessa Exports/38-workout-summary.png",
|
||||||
|
alt: "Workout summary and training log"
|
||||||
|
},
|
||||||
|
plans: {
|
||||||
|
src: "/Nessa Exports/04-plans-tab-strength.png",
|
||||||
|
alt: "AI-powered training plans and structured workouts"
|
||||||
|
},
|
||||||
|
appleHealth: {
|
||||||
|
src: "/Nessa Exports/29-apple-health.png",
|
||||||
|
alt: "Apple Health integration"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface Feature {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
free: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEATURES: readonly Feature[] = [
|
||||||
|
{
|
||||||
|
title: "Track every sport",
|
||||||
|
description:
|
||||||
|
"Activity tracking for running, cycling, swimming, and 8+ sports with full GPS, heart-rate, and duration metrics.",
|
||||||
|
free: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Segment leaderboards — free forever",
|
||||||
|
description:
|
||||||
|
"Create segments and compete on leaderboards without paying a subscription. Nessa keeps the features other apps gate behind paywalls free.",
|
||||||
|
free: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Clubs & community challenges",
|
||||||
|
description:
|
||||||
|
"Join clubs, run monthly challenges, post to the social feed, and cheer friends on with kudos and comments.",
|
||||||
|
free: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Native Apple Watch companion",
|
||||||
|
description:
|
||||||
|
"Start, follow, and finish workouts from your wrist. Built from the ground up for the Apple ecosystem with Apple Health integration.",
|
||||||
|
free: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Route planning & offline maps",
|
||||||
|
description:
|
||||||
|
"Plan routes with turn-by-turn navigation and download maps so you stay on track when coverage drops.",
|
||||||
|
free: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI training plans",
|
||||||
|
description:
|
||||||
|
"Get personalized training plans shaped around your goals, schedule, and fitness history.",
|
||||||
|
free: false
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface PricingTier {
|
||||||
|
key: "free" | "plus" | "pro";
|
||||||
|
header: string;
|
||||||
|
price: string;
|
||||||
|
badge?: string;
|
||||||
|
headline: string;
|
||||||
|
features: readonly string[];
|
||||||
|
cta: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRICING: readonly PricingTier[] = [
|
||||||
|
{
|
||||||
|
key: "free",
|
||||||
|
header: "Everything You Need",
|
||||||
|
price: "$0 — Always Free",
|
||||||
|
headline:
|
||||||
|
"Most fitness apps charge for the basics. We don't. Track your activities, compete on segments, and connect with friends — completely free.",
|
||||||
|
features: [
|
||||||
|
"Activity tracking for 8+ sports",
|
||||||
|
"Segment creation + leaderboards",
|
||||||
|
"Social feed, kudos & comments",
|
||||||
|
"Training log & activity history",
|
||||||
|
"Customizable heart-rate zones",
|
||||||
|
"Clubs & community challenges",
|
||||||
|
"Apple Watch companion app",
|
||||||
|
"Apple Health integration"
|
||||||
|
],
|
||||||
|
cta: "Get Started Free"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "plus",
|
||||||
|
header: "Take It Further",
|
||||||
|
price: "$4.99/month or $49.99/year",
|
||||||
|
badge: "17% savings with annual",
|
||||||
|
headline:
|
||||||
|
"Plan your routes, go offline, and dive deeper into your performance data. Everything you need to train smarter.",
|
||||||
|
features: [
|
||||||
|
"Route planning with turn-by-turn navigation",
|
||||||
|
"Offline maps for areas without coverage",
|
||||||
|
"Advanced segment analytics",
|
||||||
|
"Personal heatmaps showing all your adventures"
|
||||||
|
],
|
||||||
|
cta: "Start Free Trial"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pro",
|
||||||
|
header: "Train Smarter",
|
||||||
|
price: "$9.99/month or $99.99/year",
|
||||||
|
badge: "17% savings with annual",
|
||||||
|
headline:
|
||||||
|
"AI-powered training plans, advanced analytics, and premium challenges. For athletes who mean business.",
|
||||||
|
features: [
|
||||||
|
"AI-powered personalized training plans",
|
||||||
|
"Premium challenges with rewards",
|
||||||
|
"Fitness & freshness tracking",
|
||||||
|
"Matched activities for route comparison",
|
||||||
|
"Priority customer support"
|
||||||
|
],
|
||||||
|
cta: "Start Free Trial"
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface ComparisonRow {
|
||||||
|
feature: string;
|
||||||
|
strava: string;
|
||||||
|
nessa: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMPARISON: readonly ComparisonRow[] = [
|
||||||
|
{
|
||||||
|
feature: "Segment leaderboards",
|
||||||
|
strava: "Paywalled",
|
||||||
|
nessa: "Free forever"
|
||||||
|
},
|
||||||
|
{ feature: "Privacy", strava: "Server-side data", nessa: "On-device first" },
|
||||||
|
{ feature: "Premium price", strava: "$23.99/mo", nessa: "From $4.99/mo" },
|
||||||
|
{
|
||||||
|
feature: "Apple Watch",
|
||||||
|
strava: "Companion app",
|
||||||
|
nessa: "Native experience"
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WHY_NESSA = [
|
||||||
|
{
|
||||||
|
title: "Segment leaderboards free forever",
|
||||||
|
body: "Strava's most complained-about paywall is included in Nessa's free tier."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Half the price of Strava",
|
||||||
|
body: "Premium tiers at 50–60% of Strava's cost, with no surprise paywalls."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Privacy-first",
|
||||||
|
body: "Your fitness data stays on your device. No data mining, no targeted ads."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Built for Apple Watch",
|
||||||
|
body: "A native watch experience, not an afterthought. Apple Health included."
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DeletionForm from "~/components/DeletionForm";
|
import DeletionForm from "~/components/DeletionForm";
|
||||||
import {
|
import {
|
||||||
DELETION_PRODUCT_KEY,
|
DELETION_PRODUCT_KEY,
|
||||||
@@ -36,6 +37,7 @@ export default function NessaDeletionPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
|
<SubdomainHeader />
|
||||||
<div class="pt-20">
|
<div class="pt-20">
|
||||||
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
||||||
<div class="text-text w-full justify-center">
|
<div class="text-text w-full justify-center">
|
||||||
|
|||||||
@@ -1,298 +1,358 @@
|
|||||||
import { Show, For, type JSX } from "solid-js";
|
|
||||||
import { PageHead } from "~/components/PageHead";
|
|
||||||
import { useSite } from "~/context/SiteContext";
|
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
|
||||||
import { NESSA_LANDING_META } from "./meta";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nessa landing page (task 07).
|
* Nessa landing page.
|
||||||
*
|
*
|
||||||
* Net-new branded marketing home for `nessa.freno.me`. Served at the public
|
* Serves `nessa.freno.me/` (and falls back from `src/routes/index.tsx`'s
|
||||||
* URL `/` (vercel.json rewrites `nessa.freno.me/*` → the internal `/nessa/*`
|
* `useSite()` branch in dev). Reflects Nessa's actual product: a
|
||||||
* route prefix — see `src/lib/site-context.ts` + `vercel.json`).
|
* privacy-first Strava-alternative fitness app with segment leaderboards,
|
||||||
|
* clubs, challenges, Apple Watch support, and Free / Plus / Pro pricing tiers.
|
||||||
*
|
*
|
||||||
* Design contract:
|
* Content is sourced from `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`
|
||||||
* - Public — NO freno.me web-auth UI (Nessa authenticates via Clerk; the
|
* and kept in a pure `./content.ts` module so the acceptance matrix is
|
||||||
* subdomain nav in `Bars.tsx` already omits the web-auth widgets).
|
* testable without spinning up the router.
|
||||||
* - Site-aware metadata via `<PageHead>` — title suffix, canonical, and OG
|
|
||||||
* image all derive from `useSite()` (`nessa` site config).
|
|
||||||
* - Themed via `useDarkMode()` + the nessa `brandColor` (#cba6f7) accent.
|
|
||||||
* - Feature copy is derived from the real server-side capabilities in
|
|
||||||
* `nessaCommunityRouter` (clubs, challenges, social feed, events) — not
|
|
||||||
* fabricated. Nessa has no app-store presence or downloadable artifacts
|
|
||||||
* yet, so the CTA is "Coming soon" rather than a fake download link.
|
|
||||||
*
|
|
||||||
* Acceptance: `nessa.localhost:3000/` renders the landing page with hero,
|
|
||||||
* feature highlights, and a CTA; `<html data-site="nessa">` and a `<title>`
|
|
||||||
* containing "Nessa" are emitted by task-01 SSR + task-02 PageHead.
|
|
||||||
*/
|
*/
|
||||||
|
import { For, Show } from "solid-js";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { NESSA_LANDING_META } from "./meta";
|
||||||
|
import {
|
||||||
|
TAGLINE,
|
||||||
|
SUBTITLE,
|
||||||
|
ICON_DEFAULT,
|
||||||
|
ICON_DARK,
|
||||||
|
SCREENSHOTS,
|
||||||
|
FEATURES,
|
||||||
|
PRICING,
|
||||||
|
COMPARISON,
|
||||||
|
WHY_NESSA
|
||||||
|
} from "./content";
|
||||||
|
|
||||||
/** Inline SVG icon set — kept local to the page (no shared icon key needed). */
|
/** Small SVG checkmark used in pricing cards. */
|
||||||
function FeatureIcon(props: { name: string }): JSX.Element {
|
function CheckIcon() {
|
||||||
const common = (viewBox: string, path: JSX.Element) => (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
height={28}
|
viewBox="0 0 24 24"
|
||||||
width={28}
|
class="mt-0.5 h-5 w-5 shrink-0"
|
||||||
viewBox={viewBox}
|
|
||||||
class="h-full w-full"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width={2}
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{path}
|
<path d="M20 6 9 17l-5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
switch (props.name) {
|
|
||||||
case "clubs":
|
|
||||||
return common(
|
|
||||||
"0 0 24 24",
|
|
||||||
<>
|
|
||||||
<circle cx={9} cy={8} r={3} />
|
|
||||||
<circle cx={17} cy={10} r={2.5} />
|
|
||||||
<path d="M3 21c0-3.314 2.686-6 6-6s6 2.686 6 6" />
|
|
||||||
<path d="M15 21c0-2.21 1.343-4 3-4s3 1.79 3 4" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case "challenges":
|
|
||||||
return common(
|
|
||||||
"0 0 24 24",
|
|
||||||
<>
|
|
||||||
<path d="M12 2 3 7v6c0 4.97 3.806 8.74 9 9 5.194-.26 9-4.03 9-9V7l-9-5Z" />
|
|
||||||
<path d="m9 12 2 2 4-4" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case "social":
|
|
||||||
return common(
|
|
||||||
"0 0 24 24",
|
|
||||||
<>
|
|
||||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case "events":
|
|
||||||
return common(
|
|
||||||
"0 0 24 24",
|
|
||||||
<>
|
|
||||||
<rect x={3} y={4} width={18} height={18} rx={2} />
|
|
||||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single feature highlight card. */
|
|
||||||
function FeatureCard(props: {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div class="border-surface0 bg-surface0/30 hover:bg-surface0/50 flex flex-col gap-3 rounded-2xl border-2 p-6 transition-colors duration-200">
|
|
||||||
<div
|
|
||||||
class="flex h-14 w-14 items-center justify-center rounded-xl"
|
|
||||||
style={{ color: "var(--brand-color, #cba6f7)" }}
|
|
||||||
>
|
|
||||||
<FeatureIcon name={props.icon} />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-xl font-semibold">{props.title}</h3>
|
|
||||||
<p class="text-text/80 text-base leading-relaxed">{props.description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Feature {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature set derived from `nessaCommunityRouter` procedures:
|
|
||||||
* - clubs.{list,get,create,…} → "Clubs"
|
|
||||||
* - challenges.{list,get,…} → "Challenges"
|
|
||||||
* - social.{feed,getPost,likes,…} → "Social Feed"
|
|
||||||
* - events.{list,create,rsvp,…} → "Events"
|
|
||||||
*/
|
|
||||||
const FEATURES: Feature[] = [
|
|
||||||
{
|
|
||||||
icon: "clubs",
|
|
||||||
title: "Clubs",
|
|
||||||
description:
|
|
||||||
"Create and join clubs around shared interests. Public or private, with member rosters, rules, and rich profiles that keep your community organized."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "challenges",
|
|
||||||
title: "Challenges",
|
|
||||||
description:
|
|
||||||
"Run goal-driven challenges with start and end dates, track progress, and celebrate completions. Members stay motivated with live participation."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "social",
|
|
||||||
title: "Social Feed",
|
|
||||||
description:
|
|
||||||
"Share posts inside your clubs, react with likes, and reply in threaded comments. A social layer that lives right where your community already gathers."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: "events",
|
|
||||||
title: "Events",
|
|
||||||
description:
|
|
||||||
"Organize events and let members RSVP — going, maybe, or not going. Coordinate meetups and activities without leaving the club."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NessaLanding() {
|
export default function NessaLanding() {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
const { isDark } = useDarkMode();
|
const { isDark } = useDarkMode();
|
||||||
|
|
||||||
|
const iconSrc = () => (isDark() ? ICON_DARK : ICON_DEFAULT);
|
||||||
|
const brandColor = () => site().brandColor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead {...NESSA_LANDING_META} />
|
<PageHead {...NESSA_LANDING_META} />
|
||||||
|
|
||||||
|
<SubdomainHeader />
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class="relative min-h-screen w-full overflow-x-hidden"
|
class="relative min-h-screen w-full overflow-x-hidden"
|
||||||
// Drive the per-site brand accent via a CSS custom property so child
|
style={{ "--brand-color": brandColor() }}
|
||||||
// elements (feature icons, CTA) can reference `var(--brand-color)`
|
|
||||||
// without re-reading the site accessor on every render.
|
|
||||||
style={{ "--brand-color": site().brandColor }}
|
|
||||||
>
|
>
|
||||||
{/*
|
{/* Soft brand-tinted backdrop */}
|
||||||
* Soft brand-tinted backdrop. Two layered radial gradients give the
|
|
||||||
* page depth on both light and dark themes without competing with the
|
|
||||||
* content. Fixed so it stays put while the page scrolls.
|
|
||||||
*/}
|
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none fixed inset-0 z-0"
|
class="pointer-events-none fixed inset-0 z-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
background: isDark()
|
background: isDark()
|
||||||
? `radial-gradient(60% 50% at 50% 0%, ${site().brandColor}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${site().brandColor}1a 0%, transparent 70%)`
|
? `radial-gradient(60% 50% at 50% 0%, ${brandColor()}22 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}1a 0%, transparent 70%)`
|
||||||
: `radial-gradient(60% 50% at 50% 0%, ${site().brandColor}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${site().brandColor}12 0%, transparent 70%)`
|
: `radial-gradient(60% 50% at 50% 0%, ${brandColor()}18 0%, transparent 70%), radial-gradient(50% 40% at 80% 100%, ${brandColor()}12 0%, transparent 70%)`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ───────────────────────── Hero ───────────────────────── */}
|
{/* ─── Hero ───────────────────────────────────────────────── */}
|
||||||
<section class="relative z-10 flex flex-col items-center px-4 pb-16 pt-24 text-center md:pt-32">
|
<section class="relative z-10 flex flex-col items-center px-4 pt-24 pb-16 text-center md:pt-32">
|
||||||
{/* Brand mark — a CSS gradient circle standing in for the icon
|
<div class="mb-8 flex h-28 w-28 items-center justify-center rounded-[1.75rem] shadow-2xl md:h-32 md:w-32">
|
||||||
until Nessa ships a real icon asset. */}
|
<img
|
||||||
<div
|
src={iconSrc()}
|
||||||
class="mb-8 flex h-24 w-24 items-center justify-center rounded-[1.75rem] text-5xl font-black text-white shadow-lg md:h-28 md:w-28"
|
alt="Nessa app icon"
|
||||||
style={{
|
width={128}
|
||||||
background: `linear-gradient(135deg, ${site().brandColor} 0%, var(--color-mauve, #cba6f7) 100%)`
|
height={128}
|
||||||
}}
|
class="h-full w-full rounded-[1.75rem] object-cover"
|
||||||
aria-hidden="true"
|
/>
|
||||||
>
|
|
||||||
N
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-5xl font-bold tracking-tight md:text-7xl">Nessa</h1>
|
<h1 class="max-w-3xl text-4xl font-bold tracking-tight md:text-6xl">
|
||||||
|
{TAGLINE}
|
||||||
|
</h1>
|
||||||
<p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
|
<p class="text-text/85 mt-4 max-w-2xl text-lg md:text-2xl">
|
||||||
Build community. Run challenges. Stay connected.
|
{SUBTITLE}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-text/70 mt-3 max-w-xl text-base md:text-lg">
|
|
||||||
A home for your clubs — bring members together with challenges, a
|
|
||||||
social feed, and events, all in one place.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
* CTA — Nessa authenticates via Clerk and runs entirely server-side;
|
|
||||||
* there is no public app-store listing or downloadable artifact yet,
|
|
||||||
* so this is a "Coming soon" affordance rather than a fabricated
|
|
||||||
* download button. (Per task notes: don't fabricate download links.)
|
|
||||||
*/}
|
|
||||||
<div class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
|
<div class="mt-10 flex flex-col items-center gap-4 sm:flex-row">
|
||||||
<button
|
<A
|
||||||
type="button"
|
href="/contact"
|
||||||
disabled
|
class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95"
|
||||||
class="cursor-not-allowed rounded-full px-8 py-3 text-base font-semibold text-white opacity-95 shadow-md transition-transform"
|
style={{ background: brandColor() }}
|
||||||
style={{ background: site().brandColor }}
|
|
||||||
title="Nessa is coming soon"
|
|
||||||
>
|
>
|
||||||
Coming soon
|
Join the waitlist
|
||||||
</button>
|
</A>
|
||||||
<a
|
<A
|
||||||
href="/contact"
|
href="/contact"
|
||||||
class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors"
|
class="border-surface2 hover:bg-surface0/40 rounded-full border-2 px-8 py-3 text-base font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Get in touch
|
Request beta access
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-text/50 mt-4 text-sm">
|
<p class="text-text/60 mt-4 text-sm">
|
||||||
Nessa is in active development. Reach out to learn more.
|
Launching soon on the App Store.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ─────────────────────── Features ─────────────────────── */}
|
{/* ─── Free-tier feature highlights ───────────────────────── */}
|
||||||
<section class="relative z-10 px-4 py-16" id="features">
|
<section class="relative z-10 px-4 py-16" id="features">
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="mb-12 text-center">
|
<div class="mb-12 text-center">
|
||||||
<h2 class="text-3xl font-bold md:text-4xl">
|
<h2 class="text-3xl font-bold md:text-4xl">
|
||||||
Everything your club needs
|
Everything you need, free forever
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
|
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
|
||||||
Nessa brings the tools communities actually use into one
|
Nessa gives away the features other apps lock behind
|
||||||
shared space — no more juggling chats, spreadsheets, and
|
subscriptions — because your workouts shouldn't cost extra.
|
||||||
half-built trackers.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<For each={FEATURES}>
|
<For each={FEATURES}>
|
||||||
{(feature) => <FeatureCard {...feature} />}
|
{(feature) => (
|
||||||
|
<div class="border-surface0 bg-surface0/30 hover:bg-surface0/50 flex flex-col gap-3 rounded-2xl border-2 p-6 transition-colors">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-semibold">{feature.title}</h3>
|
||||||
|
<Show when={feature.free}>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
style={{ background: brandColor() }}
|
||||||
|
>
|
||||||
|
free
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<p class="text-text/80 text-base leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ──────────────────── Secondary CTA band ──────────────────── */}
|
{/* ─── Pricing tiers ───────────────────────────────────────── */}
|
||||||
<section class="relative z-10 px-4 py-20">
|
<section class="relative z-10 px-4 py-16">
|
||||||
<div class="mx-auto max-w-4xl text-center">
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<div class="mb-12 text-center">
|
||||||
<h2 class="text-3xl font-bold md:text-4xl">
|
<h2 class="text-3xl font-bold md:text-4xl">
|
||||||
Ready to grow your community?
|
Premium features, half the price of Strava
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-text/70 mx-auto mt-3 max-w-xl text-base md:text-lg">
|
<p class="text-text/70 mx-auto mt-3 max-w-2xl text-base md:text-lg">
|
||||||
Nessa is being built for clubs that want structure without the
|
Choose the plan that fits your training.
|
||||||
friction. We'll share more as it takes shape.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
</div>
|
||||||
<a
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<For each={PRICING}>
|
||||||
|
{(tier) => {
|
||||||
|
const highlighted = tier.key === "plus";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="border-surface0 bg-base/80 flex flex-col rounded-2xl border-2 p-6 backdrop-blur-sm"
|
||||||
|
classList={{
|
||||||
|
"scale-[1.02]": highlighted
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"border-color": highlighted ? brandColor() : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">{tier.header}</h3>
|
||||||
|
<div class="mt-1 text-2xl font-bold">{tier.price}</div>
|
||||||
|
<Show when={tier.badge}>
|
||||||
|
<div
|
||||||
|
class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
style={{ background: brandColor() }}
|
||||||
|
>
|
||||||
|
{tier.badge}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-text/80 mb-6 leading-relaxed">
|
||||||
|
{tier.headline}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mb-8 flex flex-col gap-3">
|
||||||
|
<For each={tier.features}>
|
||||||
|
{(item) => (
|
||||||
|
<li class="flex items-start gap-3 text-sm">
|
||||||
|
<CheckIcon />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<A
|
||||||
href="/contact"
|
href="/contact"
|
||||||
class="rounded-full px-8 py-3 text-base font-semibold text-white shadow-md transition-transform hover:scale-[1.02] active:scale-95"
|
class="mt-auto w-full rounded-full py-3 text-center text-base font-semibold transition-transform active:scale-95"
|
||||||
style={{ background: site().brandColor }}
|
classList={{
|
||||||
|
"text-white": highlighted,
|
||||||
|
"border-surface2 border-2 hover:bg-surface0/40":
|
||||||
|
!highlighted
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: highlighted ? brandColor() : undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Contact us
|
{tier.cta}
|
||||||
</a>
|
</A>
|
||||||
<a
|
</div>
|
||||||
href="/privacy"
|
);
|
||||||
class="text-text/80 hover:text-text underline-offset-4 hover:underline"
|
}}
|
||||||
>
|
</For>
|
||||||
Privacy policy
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ───────────────────────── Footer ───────────────────────── */}
|
{/* ─── Comparison vs Strava ─────────────────────────────────── */}
|
||||||
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
|
<section class="relative z-10 px-4 py-16">
|
||||||
<div class="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm text-text/60 sm:flex-row">
|
<div class="mx-auto max-w-4xl">
|
||||||
<Show
|
<h2 class="text-center text-3xl font-bold md:text-4xl">
|
||||||
when={site().subdomain}
|
See how we compare
|
||||||
fallback={<span>{site().displayName}</span>}
|
</h2>
|
||||||
|
<p class="text-text/70 mt-3 text-center text-base md:text-lg">
|
||||||
|
Get more for less with Nessa.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="border-surface0 mt-8 overflow-hidden rounded-2xl border-2">
|
||||||
|
<div class="bg-surface0/60 grid grid-cols-3 px-6 py-4 text-sm font-semibold">
|
||||||
|
<span>Feature</span>
|
||||||
|
<span class="text-center">Strava</span>
|
||||||
|
<span class="text-center" style={{ color: brandColor() }}>
|
||||||
|
Nessa
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<For each={COMPARISON}>
|
||||||
|
{(row, idx) => (
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 px-6 py-4 text-sm"
|
||||||
|
classList={{
|
||||||
|
"bg-surface0/20": idx() % 2 === 1
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<span>{row.feature}</span>
|
||||||
|
<span class="text-text/70 text-center">{row.strava}</span>
|
||||||
|
<span
|
||||||
|
class="text-center font-medium"
|
||||||
|
style={{ color: brandColor() }}
|
||||||
|
>
|
||||||
|
{row.nessa}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Why Nessa ───────────────────────────────────────────── */}
|
||||||
|
<section class="relative z-10 px-4 py-16">
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<h2 class="mb-10 text-center text-3xl font-bold md:text-4xl">
|
||||||
|
Why athletes are switching
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<For each={WHY_NESSA}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="border-surface0 bg-surface0/30 rounded-2xl border-2 p-6">
|
||||||
|
<h3 class="mb-2 text-lg font-semibold">{item.title}</h3>
|
||||||
|
<p class="text-text/80">{item.body}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Screenshots ─────────────────────────────────────────── */}
|
||||||
|
<section class="relative z-10 px-4 py-16">
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<h2 class="mb-10 text-center text-3xl font-bold md:text-4xl">
|
||||||
|
Built for your whole training life
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<For each={Object.values(SCREENSHOTS)}>
|
||||||
|
{(shot) => (
|
||||||
|
<div class="border-surface0 overflow-hidden rounded-2xl border-2">
|
||||||
|
<img
|
||||||
|
src={shot.src}
|
||||||
|
alt={shot.alt}
|
||||||
|
class="h-auto w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Final CTA ───────────────────────────────────────────── */}
|
||||||
|
<section class="relative z-10 px-4 py-20">
|
||||||
|
<div
|
||||||
|
class="mx-auto max-w-4xl rounded-3xl px-8 py-16 text-center text-white"
|
||||||
|
style={{ background: brandColor() }}
|
||||||
|
>
|
||||||
|
<h2 class="text-3xl font-bold md:text-4xl">
|
||||||
|
Ready to put your fitness first?
|
||||||
|
</h2>
|
||||||
|
<p class="mx-auto mt-3 max-w-xl text-base text-white/90 md:text-lg">
|
||||||
|
Join the waitlist and be the first to know when Nessa lands on the
|
||||||
|
App Store.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||||
|
<A
|
||||||
|
href="/contact"
|
||||||
|
class="rounded-full bg-white px-8 py-3 text-base font-semibold transition-transform hover:scale-[1.02] active:scale-95"
|
||||||
|
style={{ color: brandColor() }}
|
||||||
|
>
|
||||||
|
Join the waitlist
|
||||||
|
</A>
|
||||||
|
<A
|
||||||
|
href="/privacy"
|
||||||
|
class="text-white/90 underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Privacy policy
|
||||||
|
</A>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Footer ───────────────────────────────────────────────── */}
|
||||||
|
<footer class="border-surface0 relative z-10 border-t px-4 py-10">
|
||||||
|
<div class="text-text/60 mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 text-sm sm:flex-row">
|
||||||
<span>{site().displayName}</span>
|
<span>{site().displayName}</span>
|
||||||
</Show>
|
<A
|
||||||
<a
|
|
||||||
href="https://freno.me"
|
href="https://freno.me"
|
||||||
class="hover:text-text underline-offset-4 hover:underline"
|
class="hover:text-text underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
freno.me
|
freno.me
|
||||||
</a>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for the Nessa landing page metadata (task 07).
|
* Unit tests for the Nessa landing page metadata.
|
||||||
*
|
*
|
||||||
* Asserts the task-spec unit criterion: "PageHead on nessa site produces
|
* Mirrors the `page-head-meta.ts` testability pattern — `nessa/meta.ts` is a
|
||||||
* correct title suffix and canonical." Mirrors the `page-head-meta.ts`
|
* pure module (no solid-js / @solidjs/router / @solidjs/meta imports) so
|
||||||
* testability pattern — `nessa/meta.ts` is a pure module (no solid-js /
|
* `bun:test` can resolve it. Asserts the metadata matches Nessa's actual
|
||||||
* @solidjs/router / @solidjs/meta imports) so `bun:test` can resolve it.
|
* product positioning as a privacy-first fitness / Strava-alternative app
|
||||||
*
|
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
||||||
* The resolved values are produced by the (task-02) `resolvePageHeadMeta`
|
|
||||||
* helper over `NESSA_LANDING_META` + `SITE_CONFIG.nessa` + the browser
|
|
||||||
* pathname `/` — exactly what `<PageHead>` emits on `nessa.freno.me/`.
|
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { resolvePageHeadMeta } from "~/components/page-head-meta";
|
import { resolvePageHeadMeta } from "~/components/page-head-meta";
|
||||||
@@ -17,35 +14,71 @@ import { NESSA_LANDING_META } from "./meta";
|
|||||||
|
|
||||||
describe("Nessa landing page — PageHead metadata", () => {
|
describe("Nessa landing page — PageHead metadata", () => {
|
||||||
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => {
|
it("title composes to 'Nessa | Nessa' (base title + nessa titleSuffix)", () => {
|
||||||
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
expect(meta.title).toBe("Nessa | Nessa");
|
expect(meta.title).toBe("Nessa | Nessa");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("title contains the substring 'Nessa'", () => {
|
it("title contains the substring 'Nessa'", () => {
|
||||||
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
expect(meta.title).toContain("Nessa");
|
expect(meta.title).toContain("Nessa");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("canonical is https://nessa.freno.me/ for the landing route", () => {
|
it("canonical is https://nessa.freno.me/ for the landing route", () => {
|
||||||
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ogImage falls back to the nessa site default", () => {
|
it("ogImage falls back to the nessa site default", () => {
|
||||||
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
|
expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ogTitle uses the explicit marketing copy, not the bare title", () => {
|
it("ogTitle uses the explicit marketing copy, not the bare title", () => {
|
||||||
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
expect(meta.ogTitle).toBe(NESSA_LANDING_META.ogTitle);
|
expect(meta.ogTitle).toBe(NESSA_LANDING_META.ogTitle);
|
||||||
expect(meta.ogTitle).not.toBe("Nessa");
|
expect(meta.ogTitle).not.toBe("Nessa");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("description mentions community capabilities", () => {
|
it("description positions Nessa as a fitness app with community features", () => {
|
||||||
const meta = resolvePageHeadMeta(NESSA_LANDING_META, SITE_CONFIG.nessa, "/");
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
expect(meta.description).toBe(NESSA_LANDING_META.description);
|
expect(meta.description).toBe(NESSA_LANDING_META.description);
|
||||||
|
expect(meta.description?.toLowerCase()).toContain("fitness");
|
||||||
|
expect(meta.description?.toLowerCase()).toContain("segment");
|
||||||
expect(meta.description?.toLowerCase()).toContain("community");
|
expect(meta.description?.toLowerCase()).toContain("community");
|
||||||
expect(meta.description?.toLowerCase()).toContain("challenges");
|
expect(meta.description?.toLowerCase()).toContain("challenges");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ogDescription mentions free leaderboards and Strava comparison", () => {
|
||||||
|
const meta = resolvePageHeadMeta(
|
||||||
|
NESSA_LANDING_META,
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
|
expect(meta.ogDescription?.toLowerCase()).toContain("leaderboards");
|
||||||
|
expect(meta.ogDescription?.toLowerCase()).toContain("strava");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Deterministic `PageHead` metadata for the Nessa landing page (task 07).
|
* Deterministic `PageHead` metadata for the Nessa landing page.
|
||||||
*
|
*
|
||||||
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
* Pure module — imports NOTHING from solid-js / @solidjs/router / @solidjs/meta —
|
||||||
* so the landing-page metadata can be unit-tested in `bun:test` without
|
* so the landing-page metadata can be unit-tested in `bun:test` without
|
||||||
* spinning up the router / MetaProvider / DOM, mirroring the
|
* spinning up the router / MetaProvider / DOM, mirroring the
|
||||||
* `page-head-meta.ts` / `nav-config.ts` testability pattern.
|
* `page-head-meta.ts` / `nav-config.ts` testability pattern.
|
||||||
*
|
*
|
||||||
* `index.tsx` imports `NESSA_LANDING_META` and feeds it to `<PageHead …/>`;
|
* Nessa is positioned as a privacy-first fitness app and Strava alternative
|
||||||
* `meta.test.ts` asserts the *resolved* title / canonical / OG values against
|
* (per `~/code/Nessa/plans/2026-03-16-marketing-strategy-launch-positioning.md`).
|
||||||
* the site-aware `resolvePageHeadMeta` helper (task 02) for the `nessa` site.
|
* The description still mentions community features (clubs, challenges) because
|
||||||
*
|
* those are real free-tier capabilities, but it now leads with the product's
|
||||||
* Nessa-specific metadata contract:
|
* actual purpose: fitness tracking.
|
||||||
* - `<title>` resolves to `"Nessa | Nessa"` (props.title + nessa.titleSuffix).
|
|
||||||
* - canonical resolves to `https://nessa.freno.me/` (auto-derived from
|
|
||||||
* `site.domain` + the browser pathname `/` — the vercel.json host rewrite
|
|
||||||
* targets the internal `/nessa` prefix but leaves the public URL clean).
|
|
||||||
* - `og:image` falls back to the site's `ogDefaultImage` (`/nessa/og-default.png`).
|
|
||||||
* - description emphasizes Nessa's community capabilities (clubs, challenges,
|
|
||||||
* social feed, events) — derived from `nessaCommunityRouter`, not fabricated.
|
|
||||||
*/
|
*/
|
||||||
import type { PageHeadProps } from "~/components/page-head-meta";
|
import type { PageHeadProps } from "~/components/page-head-meta";
|
||||||
|
|
||||||
@@ -32,8 +25,8 @@ import type { PageHeadProps } from "~/components/page-head-meta";
|
|||||||
export const NESSA_LANDING_META: PageHeadProps = {
|
export const NESSA_LANDING_META: PageHeadProps = {
|
||||||
title: "Nessa",
|
title: "Nessa",
|
||||||
description:
|
description:
|
||||||
"Nessa is a community platform for building clubs, running challenges, sharing in a social feed, and organizing events.",
|
"Nessa is the fitness app that puts you first. Track running, cycling, swimming and more; compete on free segment leaderboards; and connect with friends through clubs, community challenges, and a social feed — all while keeping your data on your device.",
|
||||||
ogTitle: "Nessa — Build community. Run challenges. Stay connected.",
|
ogTitle: "Nessa — The fitness app that puts you first",
|
||||||
ogDescription:
|
ogDescription:
|
||||||
"Nessa brings clubs, challenges, social posts, and events together in one community platform."
|
"A privacy-first fitness app with segment leaderboards free forever, social clubs, community challenges, Apple Watch support, and premium tiers for less than Strava."
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function NessaPrivacyPolicy() {
|
export default function NessaPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
@@ -38,9 +39,10 @@ export default function NessaPrivacyPolicy() {
|
|||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
|
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
|
||||||
/>
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
<div class="min-h-screen px-[8vw] py-[10vh]">
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
<div class="py-4 text-xl">Nessa's Privacy Policy</div>
|
<div class="py-4 text-xl">Nessa's Privacy Policy</div>
|
||||||
<div class="py-2">Last Updated: February 9, 2026</div>
|
<div class="py-2">Last Updated: July 23, 2026</div>
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
Welcome to Nessa ('We', 'Us', 'Our').
|
Welcome to Nessa ('We', 'Us', 'Our').
|
||||||
Your privacy is important to us. This privacy policy will help you
|
Your privacy is important to us. This privacy policy will help you
|
||||||
@@ -86,18 +88,14 @@ export default function NessaPrivacyPolicy() {
|
|||||||
<div class="-ml-6">(d) Data Removal:</div> You can request the
|
<div class="-ml-6">(d) Data Removal:</div> You can request the
|
||||||
removal of your Nessa account and all associated content by
|
removal of your Nessa account and all associated content by
|
||||||
contacting us{" "}
|
contacting us{" "}
|
||||||
<A
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
href="/contact"
|
|
||||||
class="text-blue hover-underline-animation"
|
|
||||||
>
|
|
||||||
here
|
here
|
||||||
</A>
|
</A>
|
||||||
. On receipt of your request we will remove your account
|
. On receipt of your request we will remove your account record,
|
||||||
record, your club memberships, your challenges and
|
your club memberships, your challenges and participation data,
|
||||||
participation data, and your posts, comments, and likes from the
|
and your posts, comments, and likes from the Nessa database, and
|
||||||
Nessa database, and we will request that Clerk delete the
|
we will request that Clerk delete the corresponding user record.
|
||||||
corresponding user record. A short grace period may apply while
|
A short grace period may apply while the deletion is processed.
|
||||||
the deletion is processed.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,13 +118,13 @@ export default function NessaPrivacyPolicy() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="pb-2">
|
<div class="pb-2">
|
||||||
<div class="-ml-6">(b) Clerk as Identity Provider:</div> Your
|
<div class="-ml-6">(b) Clerk as Identity Provider:</div> Your
|
||||||
authentication credentials (such as your password, if you use
|
authentication credentials (such as your password, if you use an
|
||||||
an email/password sign-in) are handled exclusively by Clerk
|
email/password sign-in) are handled exclusively by Clerk under
|
||||||
under Clerk's own privacy policy. Nessa never receives,
|
Clerk's own privacy policy. Nessa never receives, stores,
|
||||||
stores, or transmits your password. When you sign in, Nessa
|
or transmits your password. When you sign in, Nessa receives a
|
||||||
receives a session token from Clerk that lets us identify you;
|
session token from Clerk that lets us identify you; this token
|
||||||
this token is verified on each request and is not persisted in
|
is verified on each request and is not persisted in our
|
||||||
our database.
|
database.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { createTRPCRouter, publicProcedure, protectedProcedure, csrfProtectedProcedure } from "../utils";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
publicProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
csrfProtectedProcedure
|
||||||
|
} from "../utils";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
S3Client,
|
S3Client,
|
||||||
@@ -42,7 +47,7 @@ export function sanitizeS3PathComponent(value: string): string {
|
|||||||
// Strip path traversal characters and normalize whitespace
|
// Strip path traversal characters and normalize whitespace
|
||||||
return value
|
return value
|
||||||
.replace(/\s+/g, "-")
|
.replace(/\s+/g, "-")
|
||||||
.replace(/[\/\\]/g, "-")
|
.replace(/[/\\]/g, "-")
|
||||||
.replace(/\.\./g, "")
|
.replace(/\.\./g, "")
|
||||||
.replace(/[^a-zA-Z0-9_-]/g, "")
|
.replace(/[^a-zA-Z0-9_-]/g, "")
|
||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
@@ -70,15 +75,17 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
|
|||||||
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
|
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
|
||||||
// tested in `bun:test` without a populated `.env`. Re-exported here for the
|
// tested in `bun:test` without a populated `.env`. Re-exported here for the
|
||||||
// tRPC mutation below + for callers that already import from `misc`.
|
// tRPC mutation below + for callers that already import from `misc`.
|
||||||
export {
|
// Import into local scope FIRST — `sendDeletionRequestEmail` below uses
|
||||||
|
// these names directly. A bare `export { ... } from` re-export does NOT make
|
||||||
|
// the bindings available locally, which caused a ReferenceError that crashed
|
||||||
|
// the entire tRPC router (503 on every /api/trpc call).
|
||||||
|
import {
|
||||||
DELETION_PRODUCT_SCHEMA,
|
DELETION_PRODUCT_SCHEMA,
|
||||||
deletionCookieName,
|
deletionCookieName,
|
||||||
deletionEmailContent
|
deletionEmailContent
|
||||||
} from "./deletion-email";
|
} from "./deletion-email";
|
||||||
export type {
|
export { DELETION_PRODUCT_SCHEMA, deletionCookieName, deletionEmailContent };
|
||||||
DeletionProduct,
|
export type { DeletionProduct, DeletionEmailContent } from "./deletion-email";
|
||||||
DeletionEmailContent
|
|
||||||
} from "./deletion-email";
|
|
||||||
|
|
||||||
const assets: Record<string, string> = {
|
const assets: Record<string, string> = {
|
||||||
"shapes-with-abigail": "shapes-with-abigail.apk",
|
"shapes-with-abigail": "shapes-with-abigail.apk",
|
||||||
@@ -378,10 +385,13 @@ export const miscRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!turnstileValid) {
|
if (!turnstileValid) {
|
||||||
console.error("Turnstile verification failed for contact form submission");
|
console.error(
|
||||||
|
"Turnstile verification failed for contact form submission"
|
||||||
|
);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Security verification failed. Please refresh the page and try again."
|
message:
|
||||||
|
"Security verification failed. Please refresh the page and try again."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { APIEvent } from "@solidjs/start/server";
|
|||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import {
|
import {
|
||||||
resolveSiteFromHost,
|
resolveSiteFromHost,
|
||||||
|
resolveSiteFromPath,
|
||||||
type Site,
|
type Site,
|
||||||
MAIN_SITE
|
MAIN_SITE
|
||||||
} from "~/lib/site-context";
|
} from "~/lib/site-context";
|
||||||
@@ -53,7 +54,9 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
|||||||
try {
|
try {
|
||||||
const nodeReq = (
|
const nodeReq = (
|
||||||
event as {
|
event as {
|
||||||
nativeEvent?: { node?: { req?: { headers?: Record<string, string | string[]> } } };
|
nativeEvent?: {
|
||||||
|
node?: { req?: { headers?: Record<string, string | string[]> } };
|
||||||
|
};
|
||||||
} | null
|
} | null
|
||||||
)?.nativeEvent?.node?.req;
|
)?.nativeEvent?.node?.req;
|
||||||
const raw = nodeReq?.headers?.host;
|
const raw = nodeReq?.headers?.host;
|
||||||
@@ -78,16 +81,76 @@ function hostFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort extraction of the URL pathname from any request-shaped event.
|
||||||
|
*
|
||||||
|
* SolidStart's FetchEvent exposes `event.request.url` (a full URL string);
|
||||||
|
* nitro/H3 exposes the raw Node request URL via `event.nativeEvent.node.req.url`.
|
||||||
|
* We try both so this works for API route handlers, the SSR document handler,
|
||||||
|
* and tRPC procedures operating on the underlying `H3Event`.
|
||||||
|
*/
|
||||||
|
function pathFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
||||||
|
// 1) Web FetchEvent / APIEvent / PageEvent → standard Request URL.
|
||||||
|
try {
|
||||||
|
const req = (event as { request?: Request } | null)?.request;
|
||||||
|
const url = req?.url;
|
||||||
|
if (url) return new URL(url).pathname;
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) vinxi/nitro H3Event → raw Node request URL.
|
||||||
|
try {
|
||||||
|
const nodeReq = (
|
||||||
|
event as {
|
||||||
|
nativeEvent?: { node?: { req?: { url?: string } } };
|
||||||
|
} | null
|
||||||
|
)?.nativeEvent?.node?.req;
|
||||||
|
const raw = nodeReq?.url;
|
||||||
|
if (typeof raw === "string" && raw) {
|
||||||
|
// Node request URLs may be path-only (`/nessa/contact`) or full URLs.
|
||||||
|
return raw.startsWith("/") ? raw : new URL(raw).pathname;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Some H3 shapes expose `event.node.req` directly.
|
||||||
|
try {
|
||||||
|
const nodeReq = (
|
||||||
|
event as {
|
||||||
|
node?: { req?: { url?: string } };
|
||||||
|
} | null
|
||||||
|
)?.node?.req;
|
||||||
|
const raw = nodeReq?.url;
|
||||||
|
if (typeof raw === "string" && raw) {
|
||||||
|
return raw.startsWith("/") ? raw : new URL(raw).pathname;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a
|
* Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a
|
||||||
* vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape,
|
* vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape,
|
||||||
* so the SSR document handler can pass its `PageEvent` directly. Falls back
|
* so the SSR document handler can pass its `PageEvent` directly. Falls back
|
||||||
* to `main` if no host can be determined.
|
* to `main` if no host or path can be determined.
|
||||||
*/
|
*/
|
||||||
export function getSiteFromEvent(
|
export function getSiteFromEvent(
|
||||||
event: APIEvent | H3Event | ServerSiteEvent
|
event: APIEvent | H3Event | ServerSiteEvent
|
||||||
): Site {
|
): Site {
|
||||||
return resolveSiteFromHost(hostFromEventLike(event));
|
const hostResult = resolveSiteFromHost(hostFromEventLike(event));
|
||||||
|
// Subdomain host (incl. `*.localhost` dev) → authoritative, use it.
|
||||||
|
if (hostResult.id !== "main") return hostResult;
|
||||||
|
|
||||||
|
// Host resolved to main — fall back to the URL path prefix. On localhost
|
||||||
|
// the vercel.json host rewrites don't run, so `localhost:3000/nessa/contact`
|
||||||
|
// has host `localhost` (→ main) but path `/nessa/contact` (→ nessa).
|
||||||
|
// Without this, every subdomain page in dev renders the main-site nav.
|
||||||
|
return resolveSiteFromPath(pathFromEventLike(event)) ?? hostResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +158,9 @@ export function getSiteFromEvent(
|
|||||||
* functions that receive a `Request` directly). Falls back to `main`.
|
* functions that receive a `Request` directly). Falls back to `main`.
|
||||||
*/
|
*/
|
||||||
export function getSiteFromRequest(request: Request): Site {
|
export function getSiteFromRequest(request: Request): Site {
|
||||||
return resolveSiteFromHost(request.headers.get("host"));
|
const hostResult = resolveSiteFromHost(request.headers.get("host"));
|
||||||
|
if (hostResult.id !== "main") return hostResult;
|
||||||
|
return resolveSiteFromPath(new URL(request.url).pathname) ?? hostResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MAIN_SITE };
|
export { MAIN_SITE };
|
||||||
|
|||||||