Skip to content

Commit c8c6389

Browse files
authored
Merge branch 'main' into mind/snapshots-polish
2 parents c5dc6f9 + d179fcf commit c8c6389

12 files changed

Lines changed: 287 additions & 7 deletions

File tree

.npmrc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# @vercel/analytics declares optional peers for other frameworks (e.g.
2+
# @sveltejs/kit) that npm's strict resolver conflicts against our vite/vitest
3+
# tree. We only use the Next.js path, so relax peer resolution for a clean,
4+
# reproducible install here and on Vercel's build.
5+
legacy-peer-deps=true

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Base
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

app/analytics/events.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Custom analytics events for omni-ui, sent to Vercel Web Analytics via track().
2+
//
3+
// These name the key conversion steps so journeys and dropoff are visible in the
4+
// Vercel Analytics Events panel. Enterprise allows up to 8 properties per event;
5+
// values must be strings/numbers/booleans/null (no nesting, <=255 chars each).
6+
//
7+
// Prefer these named helpers over calling track() inline so event names and
8+
// property shapes stay consistent.
9+
10+
import { track } from '@vercel/analytics';
11+
12+
// Side-nav navigation between the consolidated surfaces.
13+
export function trackNavClick(destination: string): void {
14+
track('nav_click', { destination });
15+
}
16+
17+
// --- Snapshots download funnel: network -> preset -> copy command ---
18+
19+
export function trackSnapshotNetworkSelect(network: string): void {
20+
track('snapshot_network_select', { network });
21+
}
22+
23+
export function trackSnapshotPresetSelect(preset: string): void {
24+
track('snapshot_preset_select', { preset });
25+
}
26+
27+
// The funnel's conversion step: copying the generated download command.
28+
export function trackSnapshotCommandCopy(network: string, preset: string | null): void {
29+
track('snapshot_command_copy', { network, preset: preset ?? 'custom' });
30+
}
31+
32+
// --- Faucet request funnel: submitted -> success | error ---
33+
34+
export type FaucetStatus = 'submitted' | 'success' | 'error';
35+
36+
export function trackFaucetRequest(token: string, status: FaucetStatus): void {
37+
track('faucet_request', { token, status });
38+
}

app/api/gate/route.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { NextResponse } from 'next/server';
2+
3+
// Verifies the temporary site password (see middleware.ts) and, on success,
4+
// sets an httpOnly cookie that the middleware checks. Reads the password from
5+
// SITE_PASSWORD; nothing is hardcoded.
6+
7+
export const runtime = 'nodejs';
8+
9+
const COOKIE = 'site_gate';
10+
const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
11+
12+
export async function POST(request: Request) {
13+
const password = process.env.SITE_PASSWORD;
14+
15+
// Gate disabled (no password configured) -> nothing to verify.
16+
if (!password) {
17+
return NextResponse.json({ ok: true });
18+
}
19+
20+
let provided = '';
21+
try {
22+
const body: unknown = await request.json();
23+
if (body && typeof body === 'object' && 'password' in body) {
24+
provided = String((body as { password: unknown }).password);
25+
}
26+
} catch {
27+
provided = '';
28+
}
29+
30+
if (provided !== password) {
31+
return NextResponse.json({ ok: false }, { status: 401 });
32+
}
33+
34+
const res = NextResponse.json({ ok: true });
35+
res.cookies.set(COOKIE, password, {
36+
httpOnly: true,
37+
secure: true,
38+
sameSite: 'strict',
39+
path: '/',
40+
maxAge: MAX_AGE_SECONDS,
41+
});
42+
return res;
43+
}

app/components/AppShell.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from 'next/link';
55
import { usePathname } from 'next/navigation';
66
import { AnimatePresence, motion } from 'motion/react';
77

8+
import { trackNavClick } from '../analytics/events';
89
import { isTabActive, NAV_ITEMS, NavIcon, tabsForPath, titleForPath } from '../navigation';
910
import { BLUE, BORDER, DISABLED, INK, MUTED, SELECTED } from '../theme';
1011
import { spectrum } from '../spectrum';
@@ -227,7 +228,14 @@ function NavRow({ icon, label, href, active, enabled, onNavigate, layoutScope =
227228

228229
if (!enabled) return row;
229230
return (
230-
<Link href={href} style={styles.navLink} onClick={onNavigate}>
231+
<Link
232+
href={href}
233+
style={styles.navLink}
234+
onClick={() => {
235+
trackNavClick(label);
236+
onNavigate?.();
237+
}}
238+
>
231239
{row}
232240
</Link>
233241
);

app/components/ui/CommandBox.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,28 @@ type CommandBoxProps = {
1010
/** Uppercase label shown in the header. */
1111
label?: string;
1212
className?: string;
13+
/** Called after the command is successfully copied (e.g. for analytics). */
14+
onCopy?: () => void;
1315
};
1416

1517
// Labeled, monospaced code block with a copy-to-clipboard button. Extracted
1618
// from the snapshots download command so any CLI/command display can reuse it.
17-
export function CommandBox({ command, label = 'Command', className }: CommandBoxProps) {
19+
export function CommandBox({ command, label = 'Command', className, onCopy }: CommandBoxProps) {
1820
const [copied, setCopied] = useState(false);
1921

2022
const handleCopy = useCallback(() => {
2123
async function copy() {
2224
try {
2325
await navigator.clipboard.writeText(command);
2426
setCopied(true);
27+
onCopy?.();
2528
setTimeout(() => setCopied(false), 2000);
2629
} catch {
2730
// ignore clipboard failures
2831
}
2932
}
3033
void copy();
31-
}, [command]);
34+
}, [command, onCopy]);
3235

3336
return (
3437
<div className={cn('overflow-hidden rounded-[10px] border border-bds-gray-10', className)}>

app/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import './globals.css';
33
import { PropsWithChildren } from 'react';
44
import localFont from 'next/font/local';
55

6+
import { Analytics } from '@vercel/analytics/next';
7+
68
import { AppShell } from './components/AppShell';
79

810
const baseSansMono = localFont({
@@ -62,6 +64,7 @@ export default function RootLayout({ children }: PropsWithChildren) {
6264
>
6365
<body>
6466
<AppShell>{children}</AppShell>
67+
<Analytics />
6568
</body>
6669
</html>
6770
);

app/snapshots/page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { EmptyState } from '../components/ui/EmptyState';
1010
import { Tabs } from '../components/ui/Tabs';
1111
import { Text } from '../components/ui/Text';
1212

13+
import {
14+
trackSnapshotCommandCopy,
15+
trackSnapshotNetworkSelect,
16+
trackSnapshotPresetSelect,
17+
} from '../analytics/events';
18+
1319
import {
1420
CHAIN_NAME_BY_NETWORK,
1521
formatBytes,
@@ -255,10 +261,16 @@ export default function SnapshotsPage() {
255261
[snapshots],
256262
);
257263

264+
function handleNetworkChange(next: string) {
265+
setNetwork(next);
266+
trackSnapshotNetworkSelect(next);
267+
}
268+
258269
function selectPreset(name: PresetName) {
259270
setPreset(name);
260271
const def = PRESETS.find((p) => p.name === name);
261272
if (def) setSelectedComponents([...def.components]);
273+
trackSnapshotPresetSelect(name);
262274
}
263275

264276
function toggleComponent(name: string) {

app/vibenet/faucet/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useCallback, useEffect, useState } from 'react';
44
import type { ChangeEvent, FormEvent, MouseEvent, ReactNode } from 'react';
55

6+
import { trackFaucetRequest } from '../../analytics/events';
67
import { Button } from '../../components/ui/Button';
78
import { Card } from '../../components/ui/Card';
89
import { cn } from '../../components/ui/cn';
@@ -78,11 +79,14 @@ export default function FaucetPage() {
7879
}
7980
setBusy(true);
8081
setDrip({ phase: 'pending', tokenId });
82+
trackFaucetRequest(tokenId, 'submitted');
8183
try {
8284
const outcome = await token.drip(address);
8385
setDrip({ phase: 'success', tokenId, outcome });
86+
trackFaucetRequest(tokenId, 'success');
8487
} catch (err) {
8588
setDrip({ phase: 'error', tokenId, message: dripErrorMessage(err) });
89+
trackFaucetRequest(tokenId, 'error');
8690
} finally {
8791
setBusy(false);
8892
refreshStatus();

middleware.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
// TEMPORARY site-wide password gate.
4+
//
5+
// Active only when SITE_PASSWORD is set. Set it in Vercel's Production
6+
// environment so the gate shows on production only; leave it unset for local
7+
// dev and preview. The password itself is never committed — it lives in the
8+
// env var.
9+
//
10+
// The gate covers UI pages only. /api/* is intentionally left public so the
11+
// snapshots API stays reachable by external consumers.
12+
//
13+
// To remove the gate later: delete this file and app/api/gate/route.ts, and
14+
// unset SITE_PASSWORD in Vercel.
15+
16+
const COOKIE = 'site_gate';
17+
18+
export function middleware(req: NextRequest) {
19+
const password = process.env.SITE_PASSWORD;
20+
21+
// No password configured (local dev / preview) -> no gate.
22+
if (!password || process.env.NODE_ENV === 'development') {
23+
return NextResponse.next();
24+
}
25+
26+
if (req.cookies.get(COOKIE)?.value === password) {
27+
return NextResponse.next();
28+
}
29+
30+
return new NextResponse(gateHtml(), {
31+
status: 401,
32+
headers: {
33+
'content-type': 'text/html; charset=utf-8',
34+
'cache-control': 'no-store',
35+
},
36+
});
37+
}
38+
39+
// Match everything except the public API, Next internals, and static assets.
40+
export const config = {
41+
matcher: ['/((?!api/|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
42+
};
43+
44+
// Self-contained gate screen (no app layout / external assets), posts the
45+
// password to /api/gate and reloads on success.
46+
function gateHtml(): string {
47+
return `<!doctype html>
48+
<html lang="en">
49+
<head>
50+
<meta charset="utf-8" />
51+
<meta name="viewport" content="width=device-width, initial-scale=1" />
52+
<meta name="robots" content="noindex" />
53+
<title>Base Labs</title>
54+
<style>
55+
:root { color-scheme: light; }
56+
* { box-sizing: border-box; }
57+
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
58+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; background:#0000ff; }
59+
.card { background:#fff; padding:32px; border-radius:14px; width:100%; max-width:340px;
60+
box-shadow:0 10px 40px rgba(0,0,0,.25); }
61+
h1 { font-size:16px; margin:0 0 4px; color:#111; }
62+
p { font-size:13px; color:#666; margin:0 0 20px; }
63+
input { width:100%; padding:10px 12px; font-size:14px; border:1px solid #ddd; border-radius:8px;
64+
outline:none; color:#111; }
65+
input:focus { border-color:#0000ff; }
66+
button { width:100%; margin-top:12px; padding:10px 12px; font-size:14px; font-weight:500;
67+
color:#fff; background:#0000ff; border:none; border-radius:8px; cursor:pointer; }
68+
.err { color:#c00; font-size:12px; margin-top:10px; min-height:16px; }
69+
</style>
70+
</head>
71+
<body>
72+
<form class="card" id="gate">
73+
<h1>Base Labs</h1>
74+
<p>This site is password protected.</p>
75+
<input id="pw" type="password" placeholder="Password" autocomplete="current-password" autofocus />
76+
<button type="submit">Enter</button>
77+
<div class="err" id="err"></div>
78+
</form>
79+
<script>
80+
var f = document.getElementById('gate');
81+
var pw = document.getElementById('pw');
82+
var err = document.getElementById('err');
83+
f.addEventListener('submit', function (ev) {
84+
ev.preventDefault();
85+
err.textContent = '';
86+
fetch('/api/gate', {
87+
method: 'POST',
88+
headers: { 'content-type': 'application/json' },
89+
body: JSON.stringify({ password: pw.value }),
90+
}).then(function (r) {
91+
if (r.ok) { location.reload(); }
92+
else { err.textContent = 'Incorrect password'; pw.value = ''; pw.focus(); }
93+
}).catch(function () { err.textContent = 'Something went wrong. Try again.'; });
94+
});
95+
</script>
96+
</body>
97+
</html>`;
98+
}

0 commit comments

Comments
 (0)