Skip to content
Merged
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
16 changes: 8 additions & 8 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,14 @@ the solo coach. They return only when a paying customer is blocked without them.

### 7.1 Coach onboarding (target: under 5 minutes)

1. Lands on `coachatron.com`, taps **Start**.
2. Enters name, phone, email. SMS one-time code verifies the phone. No password.
3. Says their week (the phone keyboard's mic, or typing) into one box on the
schedule screen, or texts it to the Coachatron number: *what it's called, how
long, how many athletes, how much, which days and times*. The product shows
what it understood, asks for anything missing (never guesses a price), and
writes nothing until the coach taps **Publish**. *(Added 2026-09-30. The one-
form session type and the weekly grid below remain as the by-hand fallback.)*
1. Lands on `coachatron.com` and types or texts their week: *what it's called, how
long, how many athletes, how much, which days and times*.
2. Enters name and phone. SMS one-time code verifies the phone (or they tap the
link we texted). No password. The model runs only after the phone is proven.
3. The product shows what it understood, asks for anything missing (never guesses
a price), and writes nothing until the coach taps **Publish**. *(Added 2026-09-30.
The landing box and the texted week feed this same setup. The one-form session
type and the weekly grid below remain as the by-hand fallback.)*
Fallback: creates a session type in one form. Every other field is defaulted.
4. Picks recurring times on a weekly grid (tap the slots). Sessions are generated
8 weeks ahead, rolling. *(Implemented: each weekly time is a `weekly_slot`,
Expand Down
3 changes: 2 additions & 1 deletion docs/PLATFORM.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ These are the whole design. Without them this feature is a liability.
"I didn't catch that" plus a deep link. Never guess a destructive action.
- **R4 — Coach-only.** NL control is authorized by the coach's verified phone
number. Athlete and roster numbers reach only the keyword layer. An unknown
number gets the public booking link and nothing else.
number never reaches the model: a stranger gets one sign-up link a day, and a
known parent gets the booking link.
- **R5 — Bounded blast radius.** One intent affects one session or one broadcast.
No "cancel everything next week" in Slice 1 — multi-entity operations go to the
web UI.
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ export const SMS_COACH_MONTHLY_CAP = 2000;
export const SMS_PRODUCT_DAILY_PER_COACH = 400;
export const SMS_PRODUCT_DAILY_FLOOR = 500;
export const OTP_DAILY_MAX_PER_PHONE = 5;
/** E.164 of the product SMS number. Empty until provisioned; the landing
* page hides the "text it" line when unset. Read at request time so tests
* can set the env without reloading the module. */
export function coachatronSmsNumber(): string {
return (process.env.COACHATRON_SMS_NUMBER ?? '').trim();
}
12 changes: 12 additions & 0 deletions src/db/migrations/0010_start_link.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- A stranger texts their week. We store it against a one-time link and
-- never call the model until they tap it (so the phone is proven).
create table if not exists start_link (
id serial primary key,
token_hash text not null unique,
phone text not null,
text text not null,
expires_at timestamptz not null,
used_at timestamptz,
created_at timestamptz not null default now()
);
create index if not exists start_link_phone on start_link (phone);
64 changes: 64 additions & 0 deletions src/domain/startLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createHash, randomBytes } from 'node:crypto';
import type { DbClient } from '../db/client.js';
import { clipStartText } from '../lib/startCookie.js';

const START_LINK_TTL_MS = 24 * 60 * 60 * 1000;

export interface StartLinkRow {
phone: string;
text: string;
}

function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

export async function createStartLink(
db: DbClient,
phone: string,
text: string,
now: Date = new Date(),
): Promise<string> {
const token = randomBytes(16).toString('hex');
const expiresAt = new Date(now.getTime() + START_LINK_TTL_MS);
await db.query('insert into start_link (token_hash, phone, text, expires_at) values ($1, $2, $3, $4)', [
tokenHash(token),
phone,
clipStartText(text),
expiresAt.toISOString(),
]);
return token;
}

export async function getOpenStartLink(
db: DbClient,
token: string,
now: Date = new Date(),
): Promise<StartLinkRow | null> {
const result = await db.query<{ phone: string; text: string; used_at: string | null; expires_at: string }>(
'select phone, text, used_at, expires_at from start_link where token_hash = $1',
[tokenHash(token)],
);
const row = result.rows[0];
if (!row || row.used_at || new Date(row.expires_at) <= now) return null;
return { phone: row.phone, text: row.text };
}

export async function consumeStartLink(
db: DbClient,
token: string,
now: Date = new Date(),
): Promise<StartLinkRow | null> {
const result = await db.query<StartLinkRow>(
`update start_link set used_at = $2
where token_hash = $1 and used_at is null and expires_at > $2
returning phone, text`,
[tokenHash(token), now.toISOString()],
);
return result.rows[0] ?? null;
}

export async function phoneHasBooking(db: DbClient, phone: string): Promise<boolean> {
const result = await db.query('select 1 from booking where contact_phone = $1 limit 1', [phone]);
return result.rows.length > 0;
}
260 changes: 260 additions & 0 deletions src/lib/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,263 @@ export function page(title: string, body: SafeHtml): string {
</body>
</html>`;
}

/** Marketing page at `/`. Same Postcard tokens as the app, but no phone-shell
* wrapper — the healthcheck stays a static 200 with no database. */
export const LANDING_CSS = `
:root {
--page: #f6e7d8;
--ink: #2c211c;
--muted: #6d5348;
--teal: #0e6b64;
--teal-ink: #f6fffd;
--clay: #c46a45;
--clay-line: #a85634;
--screen: #fffaf4;
--screen-ink: #2c211c;
--screen-muted: #7a655b;
--card: #fff;
--line: #eddccb;
--good: #0e6b64;
--touch-min: 44px;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--page);
color: var(--ink);
font-family: "Iowan Old Style", Palatino, Georgia, serif;
line-height: 1.4;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 28px 20px 64px; }
.kicker {
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--teal);
}
h1 {
font-weight: 500;
font-size: clamp(2.4rem, 8vw, 4.6rem);
line-height: 0.95;
letter-spacing: -0.03em;
margin: 12px 0 14px;
max-width: 14ch;
}
.lede {
max-width: 36rem;
font-size: 1.2rem;
margin: 0 0 22px;
}
.hero {
display: grid;
gap: 32px;
align-items: start;
}
.box label {
display: block;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 6px;
}
textarea {
width: 100%;
min-height: 8.5rem;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--card);
color: var(--ink);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 1.4;
resize: vertical;
}
button, .action {
display: inline-block;
margin-top: 12px;
width: 100%;
min-height: var(--touch-min);
text-align: center;
background: var(--teal);
color: var(--teal-ink);
border: 0;
border-radius: 999px;
padding: 12px 16px;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
line-height: 1.2;
}
.hint, .sms, .fine, .skip p, .price p, footer a {
font-family: ui-sans-serif, system-ui, sans-serif;
}
.hint { margin: 8px 0 0; color: var(--muted); font-size: 0.9rem; }
.sms { margin: 14px 0 0; font-size: 1rem; }
.sms a { color: var(--teal); font-weight: 600; }
.steps, .skip, .price { margin-top: 36px; max-width: 36rem; }
.steps h2, .skip h2, .price h2 {
font-weight: 500;
font-size: 1.6rem;
letter-spacing: -0.03em;
margin: 0 0 12px;
}
.steps ol { margin: 0; padding: 0; list-style: none; counter-reset: step; }
.steps li {
counter-increment: step;
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px 16px 14px 52px;
margin-bottom: 10px;
position: relative;
}
.steps li::before {
content: counter(step);
position: absolute;
left: 14px;
top: 14px;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--teal);
color: var(--teal-ink);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.steps strong { display: block; font-size: 1.05rem; }
.steps span {
display: block;
margin-top: 4px;
color: var(--muted);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.95rem;
}
.skip ul { margin: 0; padding: 0; list-style: none; }
.skip li {
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.skip li:last-child { border-bottom: 0; }
.price p, .skip p { color: var(--muted); margin: 0; }
.stage { display: none; }
.phone {
width: 320px;
height: 660px;
background: var(--clay);
border: 1px solid var(--clay-line);
border-radius: 40px;
padding: 10px;
}
.screen {
height: 100%;
background: var(--screen);
color: var(--screen-ink);
border-radius: 32px;
overflow: hidden;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.status {
display: flex;
justify-content: space-between;
padding: 14px 18px 0;
font-size: 12px;
font-weight: 650;
}
.body {
padding: 6px 16px 16px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
min-height: 0;
}
.eyebrow {
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--screen-muted);
margin: 6px 0 0;
}
.screen h2 {
font-family: "Iowan Old Style", Palatino, Georgia, serif;
font-weight: 500;
font-size: 30px;
line-height: 1;
letter-spacing: -0.03em;
margin: 0;
}
.sub { margin: 0; color: var(--screen-muted); font-size: 13px; }
.card {
border: 1px solid var(--line);
border-radius: 18px;
padding: 12px;
background: var(--card);
}
.card strong { display: block; font-size: 15px; }
.meta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
color: var(--screen-muted);
font-size: 13px;
}
.spots { color: var(--good); font-weight: 700; }
.btn {
margin-top: auto;
border: 0;
background: var(--teal);
color: var(--teal-ink);
border-radius: 999px;
padding: 14px 12px;
font: inherit;
font-size: 16px;
font-weight: 700;
text-align: center;
}
footer.colophon {
margin-top: 48px;
padding-top: 16px;
border-top: 1px solid #e4d0bc;
font-size: 0.95rem;
color: var(--muted);
}
footer.colophon p { margin: 0 0 8px; }
footer.colophon a { color: var(--teal); }
@media (min-width: 900px) {
.wrap { padding: 40px 28px 80px; }
.hero { grid-template-columns: 1fr 320px; gap: 48px; }
.stage { display: flex; justify-content: center; }
}
`;

export function landingPage(title: string, description: string, body: SafeHtml): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}">
<meta property="og:title" content="${escapeHtml(title)}">
<meta property="og:description" content="${escapeHtml(description)}">
<meta property="og:type" content="website">
<style>${LANDING_CSS}</style>
</head>
<body>
${body.value}
</body>
</html>`;
}
Loading
Loading