Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/varitea-landing/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Klaviyo private API key (server-only). pk_xxx
KLAVIYO_API_KEY=

# Klaviyo list to subscribe waitlist signups into.
WAITLIST_LIST_ID=

# Skip DNS MX lookup (set true in CI / test env).
WAITLIST_SKIP_MX=false

# GA4 measurement ID, e.g. G-XXXXXXX. Public — exposed to the browser.
NEXT_PUBLIC_GA4_ID=
4 changes: 4 additions & 0 deletions apps/varitea-landing/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals",
"ignorePatterns": ["node_modules/", ".next/"]
}
11 changes: 11 additions & 0 deletions apps/varitea-landing/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/node_modules
/.next
/out
/build
.env
.env*.local
*.tsbuildinfo
next-env.d.ts

# waitlist backup is runtime data, not source
/var/waitlist.jsonl
17 changes: 17 additions & 0 deletions apps/varitea-landing/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# DESIGN — Varitea landing

- **The hero is the renderer, not text.** The `<ProductTwin>` tin fills the
viewport and slowly drifts; the headline is one line of guidance over it.
Copy points at the product — it is never the medium.
- **Business-email-only is a positioning choice, not a security move.** Gating
on work email says *we're starting with operators and teams*. The rejection
is warm, not a wall: "please use a work email."
- **The flow is a journey, not a feature list.** Discover → Brew → Belong is
three video loops you move through, not three bullet points with icons. If a
section starts reading like a spec sheet, it gets redesigned.
- **Kinfolk test on every section.** Warm cream surfaces, editorial serif,
generous negative space, no gradient buttons or colored icon circles. If a
frame wouldn't sit in a Kinfolk spread, it's a regression.
- **Reduced motion is respected end to end.** `prefers-reduced-motion` kills
auto-rotate, scroll transforms, smooth scrolling, and video autoplay — the
page stays legible and calm with motion off.
83 changes: 83 additions & 0 deletions apps/varitea-landing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# @shoploop/varitea-landing

Motion-led Varitea landing page with **business-email-only** waitlist conversion.
Lives in `shoploop` because shoploop owns Varitea distribution (dogfood).

The hero embeds the Three.js product twin from `@shoploop/twin-renderer`. That
package is being built in parallel; until it lands on disk this app resolves the
import to a typed stub (`lib/twin-renderer.stub.tsx`) via an alias in
`tsconfig.json` and `vitest.config.ts`. **To swap in the real renderer:** add
`@shoploop/twin-renderer` as a dependency, delete the stub, and remove both
aliases.

## Local dev

```bash
pnpm install
cp .env.example .env.local # fill in values
pnpm dev # http://localhost:3000
```

Other scripts: `pnpm build`, `pnpm typecheck`, `pnpm lint`, `pnpm test`.

## Environment variables

| Var | Scope | Purpose |
|---|---|---|
| `KLAVIYO_API_KEY` | server | Klaviyo private key (`pk_…`) for waitlist subscribe. |
| `WAITLIST_LIST_ID` | server | Klaviyo list id signups are added to. |
| `WAITLIST_SKIP_MX` | server | `true` skips the DNS MX lookup (set in CI/test). |
| `NEXT_PUBLIC_GA4_ID` | public | GA4 measurement id (`G-…`). Omit to disable analytics. |

## Conversion gate

Business email only — a positioning filter, not a security control. We're
starting with operators and teams, so free/consumer and disposable mailboxes are
turned away with warm copy.

Validation runs twice:

1. **Client** (`components/EmailGate.tsx`) — HTML5 `type="email"` + regex +
free-provider check for instant feedback. Never trusted.
2. **Server** (`app/api/waitlist/route.ts`, source of truth) — same shape +
free + disposable checks, then a DNS **MX lookup** (`lib/mx.ts`,
skippable via `WAITLIST_SKIP_MX=true`), then Klaviyo subscribe, then a
best-effort append to `var/waitlist.jsonl` backup.

The free-provider list lives in a single `BLOCKED_FREE_DOMAINS` array in
`lib/email-gate.ts`. Disposable domains are a vendored snapshot in
`lib/disposable-domains.ts` (source cited in-file).

### API response contract

`POST /api/waitlist` always returns **200**. Body is `{ ok: true }` on success.
On rejection the body is `{ ok: false }` — except `free_domain` and
`invalid_shape`, which are client-actionable and reveal nothing about mailbox
existence, so those include `reason` for warm UX copy. `disposable`, `no_mx`,
and `klaviyo_error` are logged server-side only (avoids email enumeration and
infra leakage).

## Klaviyo list setup

1. Create a list in Klaviyo; copy its **List ID** into `WAITLIST_LIST_ID`.
2. Create a private API key with list write scope; set `KLAVIYO_API_KEY`.
3. The client uses the profile-subscription bulk-create job endpoint and treats
`409` (already subscribed) as success — submissions are idempotent.

## GA4 event spec

| Event | Param | Value |
|---|---|---|
| `waitlist_submit` | `email_domain` | the domain only (e.g. `acme.io`) — **never** the full address |

Fired client-side (`lib/ga.ts`) only after a successful `{ ok: true }` response.

## Accessibility notes

- `prefers-reduced-motion`: disables hero auto-rotate, scroll-linked transforms,
smooth scroll, and video autoplay (`lib/use-reduced-motion.ts` + globals.css).
- Renderer exposes `role="img"` with an `aria-label` ("Interactive 3D product
view …").
- Visible focus rings on every control; skip-link to the conversion section.
- Email errors use `role="alert"` + `aria-invalid` + `aria-describedby`.
- Variant selector is a labelled `radiogroup`.
105 changes: 105 additions & 0 deletions apps/varitea-landing/app/api/waitlist/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/klaviyo", () => ({
subscribeToWaitlist: vi.fn(),
}));

vi.mock("@/lib/mx", () => ({
hasMxRecord: vi.fn(),
}));

vi.mock("node:fs/promises", () => {
const appendFile = vi.fn().mockResolvedValue(undefined);
const mkdir = vi.fn().mockResolvedValue(undefined);
return { appendFile, mkdir, default: { appendFile, mkdir } };
});

import { POST } from "./route";
import { subscribeToWaitlist } from "@/lib/klaviyo";
import { hasMxRecord } from "@/lib/mx";
import { appendFile } from "node:fs/promises";

function post(email: unknown) {
return POST(
new Request("http://localhost/api/waitlist", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "Content-Type": "application/json" },
}),
);
}

beforeEach(() => {
vi.clearAllMocks();
process.env.WAITLIST_SKIP_MX = "true";
process.env.KLAVIYO_API_KEY = "pk_test";
process.env.WAITLIST_LIST_ID = "LIST123";
vi.mocked(subscribeToWaitlist).mockResolvedValue({ ok: true });
vi.mocked(hasMxRecord).mockResolvedValue(true);
});

describe("POST /api/waitlist", () => {
it("happy path: valid business email subscribes + backs up", async () => {
const res = await post("ops@varitea.co");
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ ok: true });
expect(subscribeToWaitlist).toHaveBeenCalledWith("ops@varitea.co");
expect(appendFile).toHaveBeenCalledOnce();
});

it("normalizes email before processing", async () => {
await post(" Ops@Varitea.CO ");
expect(subscribeToWaitlist).toHaveBeenCalledWith("ops@varitea.co");
});

it("rejects invalid shape (reason surfaced to client)", async () => {
const res = await post("not-an-email");
await expect(res.json()).resolves.toEqual({ ok: false, reason: "invalid_shape" });
expect(subscribeToWaitlist).not.toHaveBeenCalled();
});

it("rejects non-string / missing email as invalid shape", async () => {
const res = await post(12345);
await expect(res.json()).resolves.toEqual({ ok: false, reason: "invalid_shape" });
});

it("rejects free domain (reason surfaced to client)", async () => {
const res = await post("someone@gmail.com");
await expect(res.json()).resolves.toEqual({ ok: false, reason: "free_domain" });
expect(subscribeToWaitlist).not.toHaveBeenCalled();
});

it("rejects disposable domain (generic to client, no reason leak)", async () => {
const res = await post("burner@mailinator.com");
await expect(res.json()).resolves.toEqual({ ok: false });
expect(subscribeToWaitlist).not.toHaveBeenCalled();
});

it("rejects no-MX (generic to client) when MX check enabled", async () => {
process.env.WAITLIST_SKIP_MX = "false";
vi.mocked(hasMxRecord).mockResolvedValue(false);
const res = await post("ops@no-mx-here.io");
await expect(res.json()).resolves.toEqual({ ok: false });
expect(hasMxRecord).toHaveBeenCalled();
expect(subscribeToWaitlist).not.toHaveBeenCalled();
});

it("honors WAITLIST_SKIP_MX=true (no MX lookup)", async () => {
process.env.WAITLIST_SKIP_MX = "true";
await post("ops@varitea.co");
expect(hasMxRecord).not.toHaveBeenCalled();
});

it("maps Klaviyo failure to generic client error, no backup written", async () => {
vi.mocked(subscribeToWaitlist).mockResolvedValue({ ok: false, status: 500, body: "boom" });
const res = await post("ops@varitea.co");
await expect(res.json()).resolves.toEqual({ ok: false });
expect(appendFile).not.toHaveBeenCalled();
});

it("treats Klaviyo 409 as success (idempotent) upstream", async () => {
vi.mocked(subscribeToWaitlist).mockResolvedValue({ ok: true });
const res = await post("ops@varitea.co");
await expect(res.json()).resolves.toEqual({ ok: true });
});
});
72 changes: 72 additions & 0 deletions apps/varitea-landing/app/api/waitlist/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { appendFile, mkdir } from "node:fs/promises";
import path from "node:path";
import {
normalizeEmail,
domainOf,
isValidShape,
isFreeDomain,
isDisposableDomain,
} from "@/lib/email-gate";
import { hasMxRecord } from "@/lib/mx";
import { subscribeToWaitlist } from "@/lib/klaviyo";

export const runtime = "nodejs";

export type RejectReason =
| "free_domain"
| "disposable"
| "invalid_shape"
| "no_mx"
| "klaviyo_error";

// Reasons safe to echo to the browser. `free_domain` and `invalid_shape` are
// client-actionable and reveal nothing about whether a mailbox exists, so we
// surface them for warm UX copy. The rest could be used for email enumeration
// or leak infra detail, so the client only ever sees a generic `{ ok: false }`.
const CLIENT_VISIBLE: ReadonlySet<RejectReason> = new Set(["free_domain", "invalid_shape"]);

const BACKUP_PATH = path.join(process.cwd(), "var", "waitlist.jsonl");

async function appendBackup(email: string, domain: string): Promise<void> {
const line = JSON.stringify({ email, domain, ts: new Date().toISOString() });
try {
await mkdir(path.dirname(BACKUP_PATH), { recursive: true });
await appendFile(BACKUP_PATH, `${line}\n`, "utf8");
} catch (err) {
// Backup is best-effort; never fail the request on a disk hiccup.
console.error("[waitlist] backup append failed", err);
}
}

function reject(reason: RejectReason, status = 200) {
console.warn(`[waitlist] rejected: ${reason}`);
const body = CLIENT_VISIBLE.has(reason) ? { ok: false, reason } : { ok: false };
return NextResponse.json(body, { status });
}

export async function POST(req: Request) {
let email = "";
try {
const json = (await req.json()) as { email?: unknown };
if (typeof json.email === "string") email = normalizeEmail(json.email);
} catch {
return reject("invalid_shape");
}

if (!isValidShape(email)) return reject("invalid_shape");
if (isFreeDomain(email)) return reject("free_domain");
if (isDisposableDomain(email)) return reject("disposable");

const skipMx = process.env.WAITLIST_SKIP_MX === "true";
if (!skipMx && !(await hasMxRecord(email))) return reject("no_mx");

const result = await subscribeToWaitlist(email);
if (!result.ok) {
console.error(`[waitlist] klaviyo_error status=${result.status} body=${result.body}`);
return reject("klaviyo_error");
}

await appendBackup(email, domainOf(email));
return NextResponse.json({ ok: true });
}
29 changes: 29 additions & 0 deletions apps/varitea-landing/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
color-scheme: light;
}

html {
scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}

body {
background-color: theme("colors.surface.light");
color: theme("colors.ink.DEFAULT");
}
58 changes: 58 additions & 0 deletions apps/varitea-landing/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Instrument_Serif, Inter } from "next/font/google";
import "./globals.css";

// design.md specifies Instrument Serif display + Satoshi body. Satoshi ships via
// Fontshare (not Google), so Inter stands in as the body face until it's vendored.
const display = Instrument_Serif({
weight: "400",
subsets: ["latin"],
variable: "--font-display",
display: "swap",
});

const body = Inter({
subsets: ["latin"],
variable: "--font-body",
display: "swap",
});

export const metadata: Metadata = {
title: "Varitea — Tea, version-controlled",
description:
"Loose-leaf tea sourced in Darjeeling, brewed in Houston. Reserve your tin — early access for operators and teams.",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
const ga4 = process.env.NEXT_PUBLIC_GA4_ID;

return (
<html lang="en" className={`${display.variable} ${body.variable}`}>
<body className="font-sans antialiased">
<a
href="#reserve"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-full focus:bg-ink focus:px-4 focus:py-2 focus:text-white"
>
Skip to reserve your tin
</a>
{children}

{ga4 && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${ga4}`}
strategy="afterInteractive"
/>
<Script id="ga4-init" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${ga4}');`}
</Script>
</>
)}
</body>
</html>
);
}
Loading