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
5 changes: 4 additions & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ jobs:
- run: npm run typecheck
- run: npm test
- run: npm run build
- run: npm audit --omit=dev --audit-level=high
- run: npm audit
- run: npx playwright install --with-deps chromium
- run: npm run test:public
- run: npm run test:session
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# Delivery workflow

For every feature or change in this repo:

1. Open a pull request ready for review.
2. Review the exact PR head and resolve all actionable findings.
3. Wait for all CI checks and the deployment preview to pass.
4. Merge passing PRs to `main` automatically, then verify the merge and deployment result.

Jonas has authorized this workflow. Do not ask again before merging a PR that passes these gates.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,13 @@ Give each environment its own database and secrets. Never add the `NEXT_PUBLIC_`
| `npm run typecheck` | Generates route types, then runs `tsc --noEmit`. |
| `npm test` | Runs validation and Slack tests, and runs the real migrations and RLS policies in PGlite. |
| `npm run test:e2e` | Runs Playwright against a running app and a real Supabase project. |
| `npm run test:public` | Runs public UI regressions against a production build without Supabase credentials. |
| `npm run test:session` | Checks login outage recovery and unauthorized denial against a local mock service. |
| `npm run build` | Builds for production. |

CI (`.github/workflows/verify.yml`) runs lint, typecheck, tests, build, and `npm audit --omit=dev --audit-level=high` on every push and pull request. CI does not run the e2e tests.
CI (`.github/workflows/verify.yml`) runs lint, typecheck, tests, build, `npm audit` across production and development dependencies at every severity, public UI browser regressions, and local session-recovery checks on every push and pull request. CI does not run the authenticated provider e2e tests.

To run public UI regressions locally, run `npm run build`, `npx playwright install chromium`, and `npm run test:public`. The tests start their own production server on port 3012.

The e2e specs (`workflow`, `passkey`, `newsletter`) create fictional records and delete them afterwards. Start the app before running them, and point them only at a test environment. A spec fails if configuration is missing. It does not skip.

Expand Down
50 changes: 40 additions & 10 deletions app/_home/interactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,47 @@ const ThemeContext = createContext({ dark: true, toggle: () => {} });
const THEME_KEY = 'theme';

const themeListeners = new Set<() => void>();
let transientTheme: boolean | undefined;

function preferredTheme() {
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light') return stored === 'dark';
if (transientTheme !== undefined) return transientTheme;
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light') return stored === 'dark';
} catch {
// Browser privacy settings can block storage while leaving the site usable.
}
return !window.matchMedia('(prefers-color-scheme: light)').matches;
}

function subscribeTheme(notify: () => void) {
const query = window.matchMedia('(prefers-color-scheme: light)');
const storageChanged = (event: StorageEvent) => {
if (event.key === THEME_KEY || event.key === null) notify();
};
themeListeners.add(notify);
query.addEventListener('change', notify);
window.addEventListener('storage', storageChanged);
return () => {
themeListeners.delete(notify);
query.removeEventListener('change', notify);
window.removeEventListener('storage', storageChanged);
};
}

function storeTheme(dark: boolean) {
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
transientTheme = dark;
try {
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
transientTheme = undefined;
} catch {
// Keep the choice in memory when persistence is unavailable.
}
for (const notify of themeListeners) notify();
}

// Applies the stored theme before hydration so a light-mode visitor never sees a dark frame.
const themeScript = `(function(){try{var e=document.getElementById('top');if(!e)return;var s=localStorage.getItem('${THEME_KEY}');var d=s?s==='dark':!matchMedia('(prefers-color-scheme: light)').matches;e.setAttribute('data-theme',d?'dark':'light');document.documentElement.classList.toggle('dark',d)}catch(_){}})()`;
const themeScript = `(function(){try{var e=document.getElementById('top');if(!e)return;var s=localStorage.getItem('${THEME_KEY}');var d=s==='dark'||s==='light'?s==='dark':!matchMedia('(prefers-color-scheme: light)').matches;e.setAttribute('data-theme',d?'dark':'light');document.documentElement.classList.toggle('dark',d)}catch(_){}})()`;

export function PageShell({ children }: { children: ReactNode }) {
const dark = useSyncExternalStore(subscribeTheme, preferredTheme, () => true);
Expand Down Expand Up @@ -149,12 +166,23 @@ export function Header({
const toggleRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!mobile) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const menu = menuRef.current;
const desktop = window.matchMedia('(min-width: 1024px)');
const closeOnDesktop = () => {
if (desktop.matches && !document.querySelector('dialog[open]'))
setMobile(false);
};
desktop.addEventListener('change', closeOnDesktop);
document.addEventListener('close', closeOnDesktop, true);
menu?.querySelector<HTMLElement>('a, button')?.focus();
function trap(event: KeyboardEvent) {
if (event.key !== 'Tab' || !menu) return;
if (
event.key !== 'Tab' ||
!menu ||
desktop.matches ||
document.querySelector('dialog[open]')
)
return;
const stops = [
toggleRef.current,
...menu.querySelectorAll<HTMLElement>('a, button'),
Expand All @@ -172,12 +200,14 @@ export function Header({
}
document.addEventListener('keydown', trap);
return () => {
document.body.style.overflow = previous;
desktop.removeEventListener('change', closeOnDesktop);
document.removeEventListener('close', closeOnDesktop, true);
document.removeEventListener('keydown', trap);
};
}, [mobile]);
useEffect(() => {
function close(event: PointerEvent) {
if (document.querySelector('dialog[open]')) return;
if (
event.target instanceof Node &&
!ref.current?.contains(event.target)
Expand All @@ -187,7 +217,7 @@ export function Header({
}
}
function escape(event: KeyboardEvent) {
if (event.key === 'Escape') {
if (event.key === 'Escape' && !document.querySelector('dialog[open]')) {
setActive(null);
setMobile((open) => {
if (open) toggleRef.current?.focus();
Expand Down Expand Up @@ -335,7 +365,7 @@ export function Header({
Newsletter
</a>
)}
<InquiryButton onOpen={() => setMobile(false)} />
<InquiryButton />
</div>
)}
</header>
Expand Down
5 changes: 5 additions & 0 deletions app/_home/new.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
@media (max-width: 1023px) {
body:has(.meridian-page .mobile-dropdown) {
overflow: hidden;
}
}
.meridian-page {
--font-inter: Inter, Arial, sans-serif;
--font-fraunces: Fraunces, Georgia, serif;
Expand Down
8 changes: 5 additions & 3 deletions app/admin/(workspace)/customers/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { adminListFiltersSchema } from '@/lib/crm/validation';
import Link from 'next/link';
import { Pagination, pageNumber } from '../../pagination';
import { getCustomers } from '@/lib/crm/queries';
import { DateLabel, Hero, SearchIcon } from '../../shared';
export default async function Customers({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const filters = await searchParams;
const page = pageNumber(filters.page);
const params = await searchParams;
const filters = adminListFiltersSchema.parse(params);
const page = pageNumber(params.page);
const customers = await getCustomers({ q: filters.q, page });
const visible = customers.slice(0, 50);
return (
Expand Down
8 changes: 5 additions & 3 deletions app/admin/(workspace)/requests/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { adminListFiltersSchema } from '@/lib/crm/validation';
import Link from 'next/link';
import { Pagination, pageNumber } from '../../pagination';
import { getRequests } from '@/lib/crm/queries';
import { Hero, RequestTable, SearchIcon, statusLabels } from '../../shared';
export default async function Requests({
searchParams,
}: {
searchParams: Promise<{ q?: string; status?: string; page?: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const filters = await searchParams;
const page = pageNumber(filters.page);
const params = await searchParams;
const filters = adminListFiltersSchema.parse(params);
const page = pageNumber(params.page);
const requests = await getRequests({ ...filters, page });
const visible = requests.slice(0, 50);
const filtered = Boolean(filters.q || filters.status);
Expand Down
4 changes: 4 additions & 0 deletions app/admin/auth-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export function LoginForm() {
return;
}
const response = await fetch('/api/admin/session', { method: 'POST' });
if (response.status >= 500) {
setError('Admin access is temporarily unavailable. Please try again.');
return;
}
if (!response.ok) {
await supabase.auth.signOut({ scope: 'local' });
setError('This account cannot access the workspace.');
Expand Down
12 changes: 10 additions & 2 deletions app/admin/forms.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useActionState, useRef, type ReactNode } from 'react';
import { startTransition, useActionState, useRef, type ReactNode } from 'react';
import type { ActionState } from './actions';

type AdminAction = (
Expand Down Expand Up @@ -40,7 +40,15 @@ export function AdminForm({
{},
);
return (
<form ref={ref} action={submit} className="cs-form">
<form
ref={ref}
className="cs-form"
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
startTransition(() => submit(data));
}}
>
<input type="hidden" name="id" value={id} />
<fieldset disabled={pending}>
{children}
Expand Down
18 changes: 17 additions & 1 deletion app/api/admin/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isAuthRetryableFetchError } from '@supabase/supabase-js';
import { isAllowedOrigin } from '@/lib/crm/origin';
import { createSessionClient } from '@/lib/supabase/server';
export async function POST(request: Request) {
Expand All @@ -9,14 +10,29 @@ export async function POST(request: Request) {
data: { user },
error,
} = await supabase.auth.getUser();
if (
error &&
(isAuthRetryableFetchError(error) ||
error.status === 429 ||
(error.status ?? 0) >= 500)
)
return Response.json(
{ error: 'Admin access is temporarily unavailable.' },
{ status: 503 },
);
if (error || !user)
return Response.json({ error: 'Sign in to continue.' }, { status: 401 });
const { data: member, error: memberError } = await supabase
.from('admin_users')
.select('user_id')
.eq('user_id', user.id)
.maybeSingle();
if (memberError || !member) {
if (memberError)
return Response.json(
{ error: 'Admin access is temporarily unavailable.' },
{ status: 503 },
);
if (!member) {
await supabase.auth.signOut();
return Response.json(
{ error: 'This account does not have admin access.' },
Expand Down
4 changes: 4 additions & 0 deletions components/inquiry-form.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
body:has(.inquiry-dialog[open]) {
overflow: hidden;
}
.inquiry-dialog,
.inquiry-form {
--inquiry-accent: oklch(0.92 0 0);
Expand All @@ -8,6 +11,7 @@
color: oklch(0.95 0 0);
}
.inquiry-dialog {
font-family: Inter, Arial, sans-serif;
background: oklch(0.195 0 0);
border: 1px solid var(--inquiry-line);
border-radius: 28px;
Expand Down
47 changes: 23 additions & 24 deletions components/inquiry-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import Link from 'next/link';
import { createPortal } from 'react-dom';
import { Check, Globe2, Layers3, PanelsTopLeft, Sparkles } from 'lucide-react';
import { useEffect, useId, useRef, useState, type SyntheticEvent } from 'react';
import './inquiry-form.css';
Expand Down Expand Up @@ -362,51 +363,49 @@ export function InquiryForm({ caseStudy }: { caseStudy?: Study }) {
export function InquiryButton({
label = 'Start your project',
className = 'new-button primary',
onOpen,
}: {
label?: string;
className?: string;
onOpen?: () => void;
} = {}) {
const [open, setOpen] = useState(false);
const dialog = useRef<HTMLDialogElement>(null);
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
const id = useId();
useEffect(() => {
if (!open) return;
dialog.current?.showModal();
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
return (
<>
<button
className={className}
onClick={() => {
setPortalTarget(document.body);
setOpen(true);
onOpen?.();
}}
>
{label} <span aria-hidden="true">↗</span>
</button>
<dialog
ref={dialog}
className="inquiry-dialog"
aria-labelledby={id}
onClose={() => setOpen(false)}
>
<button
className="inquiry-close"
aria-label="Close request"
onClick={() => dialog.current?.close()}
>
×
</button>
<h2 id={id}>Let&apos;s talk about your idea.</h2>
<InquiryForm />
</dialog>
{portalTarget &&
createPortal(
<dialog
ref={dialog}
className="inquiry-dialog"
aria-labelledby={id}
onClose={() => setOpen(false)}
>
<button
className="inquiry-close"
aria-label="Close request"
onClick={() => dialog.current?.close()}
>
×
</button>
<h2 id={id}>Let&apos;s talk about your idea.</h2>
<InquiryForm />
</dialog>,
portalTarget,
)}
</>
);
}
2 changes: 1 addition & 1 deletion docs/production-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Complete this list before promoting a release that changes inquiries, auth, or t
## Automated checks

```sh
npm run lint && npm run typecheck && npm test && npm run build && npm audit --omit=dev
npm run lint && npm run typecheck && npm test && npm run build && npm audit
```

Then run `npm run test:e2e` against the deployed test environment.
Expand Down
4 changes: 2 additions & 2 deletions lib/crm/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'server-only';
import { z } from 'zod';
import { requireOwner } from './auth';
import { idSchema, statusSchema } from './validation';
import { emailSchema, idSchema, statusSchema } from './validation';
function parse<T>(schema: z.ZodType<T>, input: unknown): T {
const result = schema.safeParse(input);
if (!result.success)
Expand Down Expand Up @@ -110,7 +110,7 @@ export async function updateCustomer(input: {
z.object({
id: idSchema,
name: z.string().trim().min(2).max(120),
email: z.email().trim().toLowerCase().max(254),
email: emailSchema,
company: z.string().trim().max(160),
status: z.enum(['active', 'inactive']),
}),
Expand Down
Loading
Loading