major great updates
This commit is contained in:
parent
d7acae8a2c
commit
e3a2d052a7
8 changed files with 257 additions and 56 deletions
77
bin/joins.js
Normal file
77
bin/joins.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env node
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// bin/joins.js — read the Bifrost join-click log.
|
||||
//
|
||||
// Every press of the final "Join Project Bifrost" CTA is recorded
|
||||
// as its own row in the `bifrost_joins` table (who, when, session).
|
||||
//
|
||||
// Usage:
|
||||
// node bin/joins.js list # every click, newest first
|
||||
// node bin/joins.js summary # one row per user, with click count
|
||||
// node bin/joins.js for <email> # full click history for one user
|
||||
// node bin/joins.js stats # totals (clicks + unique users)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
import { q } from '../src/db.js';
|
||||
|
||||
const [, , cmd, arg] = process.argv;
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
function help() {
|
||||
console.log('Usage:');
|
||||
console.log(' joins list # every click, newest first');
|
||||
console.log(' joins summary # one row per user');
|
||||
console.log(' joins for <email> # click history for a user');
|
||||
console.log(' joins stats # totals');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function iso(t) { return new Date(t).toISOString(); }
|
||||
function shortSid(s) { return s ? `[${s.slice(0, 8)}…]` : ''; }
|
||||
|
||||
switch (cmd) {
|
||||
case 'list': {
|
||||
const rows = q.listJoins.all();
|
||||
if (rows.length === 0) { console.log('(no clicks yet)'); break; }
|
||||
for (const r of rows) {
|
||||
console.log(` ${iso(r.clicked_at)} ${r.email.padEnd(32)} ${shortSid(r.session_id)}`);
|
||||
}
|
||||
console.log(`\n${rows.length} click${rows.length === 1 ? '' : 's'} total.`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'summary': {
|
||||
const rows = q.summariseJoins.all();
|
||||
if (rows.length === 0) { console.log('(no clicks yet)'); break; }
|
||||
console.log(' CLICKS FIRST LAST EMAIL');
|
||||
for (const r of rows) {
|
||||
const n = String(r.click_count).padStart(6);
|
||||
console.log(` ${n} ${iso(r.first_clicked_at)} ${iso(r.last_clicked_at)} ${r.email}`);
|
||||
}
|
||||
console.log(`\n${rows.length} unique user${rows.length === 1 ? '' : 's'}.`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'for': {
|
||||
if (!arg || !EMAIL_RE.test(arg)) help();
|
||||
const email = arg.trim().toLowerCase();
|
||||
const rows = q.listJoinsForEmail.all(email);
|
||||
if (rows.length === 0) { console.log(`(no clicks for ${email})`); break; }
|
||||
console.log(`Clicks by ${email}:`);
|
||||
for (const r of rows) {
|
||||
console.log(` ${iso(r.clicked_at)} ${shortSid(r.session_id)}`);
|
||||
}
|
||||
console.log(`\n${rows.length} click${rows.length === 1 ? '' : 's'}.`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
const total = q.countJoins.get().n;
|
||||
const unique = q.countUniqueJoiners.get().n;
|
||||
console.log(` total clicks: ${total}`);
|
||||
console.log(` unique users: ${unique}`);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
help();
|
||||
}
|
||||
|
|
@ -1078,6 +1078,19 @@
|
|||
if (joinBtn.disabled) return;
|
||||
joinBtn.disabled = true;
|
||||
|
||||
// Record the click on the server. Fire-and-forget — the UI
|
||||
// transitions below run regardless of network outcome so a
|
||||
// temporary failure doesn't trap the user in a broken state.
|
||||
// The server uses INSERT OR IGNORE keyed on email, so repeat
|
||||
// clicks from the same user are safely deduplicated.
|
||||
fetch('/api/bifrost-join', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}).catch(() => {
|
||||
// Network/server error — intentionally swallowed. An admin
|
||||
// listing missing entries can follow up out-of-band.
|
||||
});
|
||||
|
||||
// Kill the CTA's scroll-reveal trigger so scrolling up + back
|
||||
// down can't replay the reveal and bring the CTA back over the
|
||||
// confirmation. After click, the CTA stays in whatever state
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 369 KiB |
|
|
@ -238,7 +238,7 @@
|
|||
transform: translate3d(0,0,0);
|
||||
display: flex; align-items: center;
|
||||
padding: 0 120px;
|
||||
--spine-y: 64%;
|
||||
--spine-y: 54%;
|
||||
}
|
||||
|
||||
.spine {
|
||||
|
|
@ -281,8 +281,18 @@
|
|||
/* Card */
|
||||
.evt {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
padding: 16px 20px 18px;
|
||||
width: 640px;
|
||||
padding: 32px 40px 36px;
|
||||
/* Two-column editorial layout: headline on the left (15% wider),
|
||||
body paragraph + source stacked on the right. Keeps cards shorter
|
||||
so they don't clip below the timeline spine. */
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
grid-template-areas:
|
||||
"head body"
|
||||
"head source";
|
||||
column-gap: 32px;
|
||||
row-gap: 14px;
|
||||
background: var(--paper-high);
|
||||
/* Tonal surface shifts instead of 1px borders */
|
||||
box-shadow:
|
||||
|
|
@ -318,7 +328,7 @@
|
|||
.evt::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
left: 52px;
|
||||
width: 1px;
|
||||
background: rgba(56,56,49,0.28);
|
||||
}
|
||||
|
|
@ -328,7 +338,7 @@
|
|||
/* Node on the spine — tiny dot */
|
||||
.evt .node {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
left: 46px;
|
||||
width: 13px; height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--paper-high);
|
||||
|
|
@ -373,30 +383,39 @@
|
|||
.evt[data-accent="crimson"] .kind { color: var(--crimson); }
|
||||
|
||||
.evt h3 {
|
||||
grid-area: head;
|
||||
align-self: start;
|
||||
font-family: "Newsreader", Georgia, serif;
|
||||
font-weight: 400;
|
||||
font-size: 17px;
|
||||
line-height: 1.22;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 41px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--ink);
|
||||
margin: 0 0 8px 0;
|
||||
margin: 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
/* Bold-italic emphasis in headlines gets the red accent so each card
|
||||
carries a consistent touch of colour while staying editorial. */
|
||||
.evt h3 em {
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
color: var(--crimson);
|
||||
}
|
||||
|
||||
.evt p {
|
||||
grid-area: body;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-soft);
|
||||
text-wrap: pretty;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.evt .source {
|
||||
margin-top: 10px;
|
||||
grid-area: source;
|
||||
align-self: end;
|
||||
margin-top: 0;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -404,27 +423,32 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ───────── Continue button ───────── */
|
||||
/* ───────── Continue button ─────────
|
||||
Right-side anchor, vertically centered so readers crossing the
|
||||
timeline can't miss it. Larger editorial block with a circular
|
||||
icon on the left and the label on the right. Breath animation
|
||||
translates horizontally only; `translateY(-50%)` is applied via
|
||||
`top: 50%` centering and kept stable across keyframes. */
|
||||
.continue-btn {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
right: 72px;
|
||||
bottom: 140px;
|
||||
top: 50%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 22px;
|
||||
padding: 20px 28px;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 32px 44px;
|
||||
background: var(--paper-high);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
opacity: 0;
|
||||
transform: translateX(36px);
|
||||
transform: translate(36px, -50%);
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(56,56,49,0.06),
|
||||
0 18px 32px -18px rgba(56,56,49,0.22),
|
||||
0 2px 6px -3px rgba(56,56,49,0.08);
|
||||
0 0 0 0.5px rgba(56,56,49,0.08),
|
||||
0 26px 48px -22px rgba(56,56,49,0.28),
|
||||
0 3px 10px -4px rgba(56,56,49,0.10);
|
||||
transition:
|
||||
opacity 520ms var(--ease),
|
||||
transform 520ms var(--ease),
|
||||
|
|
@ -433,42 +457,52 @@
|
|||
}
|
||||
.continue-btn.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform: translate(0, -50%);
|
||||
pointer-events: auto;
|
||||
animation: continue-breath 2800ms cubic-bezier(0.2, 0, 0, 1) infinite;
|
||||
}
|
||||
@keyframes continue-breath {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(6px); }
|
||||
0%, 100% { transform: translate(0, -50%); }
|
||||
50% { transform: translate(6px, -50%); }
|
||||
}
|
||||
.continue-btn:hover {
|
||||
background: #fffbf2;
|
||||
box-shadow:
|
||||
0 0 0 0.5px rgba(56,56,49,0.10),
|
||||
0 24px 40px -20px rgba(56,56,49,0.28),
|
||||
0 3px 8px -4px rgba(56,56,49,0.10);
|
||||
0 0 0 0.5px rgba(56,56,49,0.12),
|
||||
0 32px 56px -22px rgba(56,56,49,0.34),
|
||||
0 4px 12px -5px rgba(56,56,49,0.12);
|
||||
}
|
||||
.continue-btn .c-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
color: var(--crimson);
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
.continue-btn .c-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.continue-btn:hover .c-icon {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.continue-btn .c-label {
|
||||
font-family: "Newsreader", Georgia, serif;
|
||||
font-size: 20px;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
line-height: 1.08;
|
||||
max-width: 13ch;
|
||||
}
|
||||
.continue-btn .c-label em {
|
||||
font-style: italic; font-weight: 700;
|
||||
}
|
||||
.continue-btn .c-arrow {
|
||||
font-family: "Newsreader", Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--crimson);
|
||||
line-height: 1;
|
||||
transition: transform var(--dur) var(--ease);
|
||||
}
|
||||
.continue-btn:hover .c-arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* ───────── Overview page ───────── */
|
||||
|
|
@ -1253,14 +1287,19 @@ html {
|
|||
font-family: var(--type-display);
|
||||
font-weight: 320;
|
||||
font-size: clamp(3rem, 10vw, 9rem);
|
||||
line-height: 0.95;
|
||||
/* Loosened from 0.95 to 1.12 and padded so the italic "Bifrost"
|
||||
token's ascenders/descenders clear the .bifrost-pin's
|
||||
overflow:hidden (which exists to clip the aurora arc). */
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
padding: 0.12em 0.08em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.15em;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
}
|
||||
.bifrost-name .token {
|
||||
display: inline-block;
|
||||
|
|
@ -2176,8 +2215,14 @@ html {
|
|||
|
||||
<!-- Continue to the next page -->
|
||||
<button class="continue-btn" id="continue-btn" type="button">
|
||||
<span class="c-label">Read the editor’s <em>note</em></span>
|
||||
<span class="c-arrow" aria-hidden="true">→</span>
|
||||
<span class="c-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<circle cx="24" cy="24" r="22.5"/>
|
||||
<path d="M16 24 H32 M26 17.5 L32.5 24 L26 30.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="c-label">How Fenja AI <em>addresses</em> this</span>
|
||||
</button>
|
||||
|
||||
<div class="timeline-viewport" id="tl-viewport">
|
||||
|
|
@ -2209,11 +2254,11 @@ html {
|
|||
<div>
|
||||
<div class="eyebrow" data-reveal>For regulated environments</div>
|
||||
<h1 id="hero-title" class="hero-title" data-reveal-lines>
|
||||
Secure & <em>Sovereign</em> AI,<br/>
|
||||
Fenja AI — Secure & <em>Sovereign,</em><br/>
|
||||
hosted where it <em>belongs.</em>
|
||||
</h1>
|
||||
<p class="hero-lede" data-reveal>
|
||||
Enabling highly advanced AI capabilities hosted within the client's own secure infrastructure.
|
||||
Fenja AI is a sovereign AI platform, enabling highly advanced AI capabilities hosted within the client's own secure infrastructure.
|
||||
</p>
|
||||
|
||||
<!-- Hero foot: "Supported by Innovationsfonden" on the left and
|
||||
|
|
|
|||
|
|
@ -68,9 +68,9 @@ const EVENTS = [
|
|||
/* ─────────────────────────────────────────────────────────────
|
||||
Timeline layout
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD_PITCH = 380; // horizontal spacing between card centers
|
||||
const LEFT_PAD = 520; // extra room so first above-card clears the title/sub
|
||||
const RIGHT_PAD = 280;
|
||||
const CARD_PITCH = 760; // horizontal spacing between card centers (doubled to fit 640-wide cards)
|
||||
const LEFT_PAD = 720; // extra room so first above-card clears the title/sub
|
||||
const RIGHT_PAD = 420;
|
||||
|
||||
const track = document.getElementById('tl-track');
|
||||
const viewport = document.getElementById('tl-viewport');
|
||||
|
|
@ -86,16 +86,12 @@ function buildTimeline() {
|
|||
const card = document.createElement('article');
|
||||
card.className = 'evt ' + (above ? 'above' : 'below');
|
||||
card.dataset.accent = e.accent;
|
||||
card.style.left = (x - 160) + 'px';
|
||||
card.style.left = (x - 320) + 'px';
|
||||
card.innerHTML = `
|
||||
<span class="node"></span>
|
||||
<div class="tag-row">
|
||||
<span class="date">${e.date}</span>
|
||||
<span class="kind">${e.kind}</span>
|
||||
</div>
|
||||
<h3>${e.hed}</h3>
|
||||
<p>${e.body}</p>
|
||||
<div class="source">${e.source}</div>
|
||||
<div class="source">${e.source} · ${e.date}</div>
|
||||
`;
|
||||
track.appendChild(card);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -297,8 +297,9 @@
|
|||
and where, will shape the next decades.
|
||||
</p>
|
||||
<p class="welcome-body">
|
||||
What follows is a timeline: twenty-three moments that explain why
|
||||
this matters now, and what the path looks like.
|
||||
What follows is a timeline: twelve moments that explain why
|
||||
this matters now, and — at the end — a note on how
|
||||
Fenja AI addresses it.
|
||||
</p>
|
||||
<button type="button" class="welcome-cta" id="welcome-continue">
|
||||
<svg class="c-icon" width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
|
|
|
|||
12
server.js
12
server.js
|
|
@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
|||
|
||||
import authRouter from './src/auth.js';
|
||||
import { requireAuth } from './src/middleware.js';
|
||||
import './src/db.js'; // side-effect import: opens DB + runs schema
|
||||
import { q } from './src/db.js'; // also side-effect: opens DB + runs schema
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
|
@ -65,6 +65,16 @@ app.use((req, res, next) => {
|
|||
// ─── Auth endpoints (public) ─────────────────────────────────
|
||||
app.use('/auth', authRouter);
|
||||
|
||||
// ─── Bifrost join tracking (gated) ───────────────────────────
|
||||
// Records every click of the final CTA button as its own row so the
|
||||
// full history per user is preserved (who, when, which session).
|
||||
app.post('/api/bifrost-join', requireAuth, (req, res) => {
|
||||
const { email, id: sessionId } = req.session;
|
||||
const clickedAt = Date.now();
|
||||
q.recordJoin.run(email, clickedAt, sessionId);
|
||||
res.json({ clicked_at: clickedAt });
|
||||
});
|
||||
|
||||
// ─── Root dispatch ───────────────────────────────────────────
|
||||
// GET / → always the entrance shell. If authed, entrance.js routes
|
||||
// the user to the welcome step client-side.
|
||||
|
|
|
|||
59
src/db.js
59
src/db.js
|
|
@ -46,6 +46,16 @@ db.exec(`
|
|||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_limits(window_end);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bifrost_joins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
clicked_at INTEGER NOT NULL,
|
||||
session_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_email ON bifrost_joins(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_clicked_at ON bifrost_joins(clicked_at);
|
||||
`);
|
||||
|
||||
// ─── Migrations ──────────────────────────────────────────────
|
||||
|
|
@ -69,6 +79,30 @@ if (!inviteCols.some((c) => c.name === 'first_name')) {
|
|||
db.exec(`DROP TABLE IF EXISTS codes`);
|
||||
db.exec(`DROP INDEX IF EXISTS idx_codes_expires`);
|
||||
|
||||
// Migration 3: bifrost_joins schema expanded to one-row-per-click.
|
||||
// The first version of the table used `email` as PRIMARY KEY with
|
||||
// INSERT OR IGNORE — only the first click was recorded per user.
|
||||
// The new table has an auto-increment `id` so every click is stored,
|
||||
// enabling per-user click history. Detect the old schema by the
|
||||
// absence of an `id` column; if found, rename, rebuild, copy, drop.
|
||||
const joinCols = db.prepare(`PRAGMA table_info(bifrost_joins)`).all();
|
||||
if (joinCols.length > 0 && !joinCols.some((c) => c.name === 'id')) {
|
||||
db.exec(`
|
||||
ALTER TABLE bifrost_joins RENAME TO bifrost_joins_old;
|
||||
CREATE TABLE bifrost_joins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
clicked_at INTEGER NOT NULL,
|
||||
session_id TEXT
|
||||
);
|
||||
INSERT INTO bifrost_joins (email, clicked_at, session_id)
|
||||
SELECT email, clicked_at, session_id FROM bifrost_joins_old;
|
||||
DROP TABLE bifrost_joins_old;
|
||||
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_email ON bifrost_joins(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_bifrost_joins_clicked_at ON bifrost_joins(clicked_at);
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Prepared statements ─────────────────────────────────────
|
||||
export const q = {
|
||||
// invites
|
||||
|
|
@ -102,6 +136,31 @@ export const q = {
|
|||
window_end = excluded.window_end`
|
||||
),
|
||||
|
||||
// bifrost joins — one row per click of the final CTA. Every press
|
||||
// is logged with the user's email, timestamp, and session ID so the
|
||||
// full history is available per user and in aggregate.
|
||||
recordJoin: db.prepare(
|
||||
`INSERT INTO bifrost_joins (email, clicked_at, session_id) VALUES (?, ?, ?)`
|
||||
),
|
||||
listJoins: db.prepare(
|
||||
`SELECT id, email, clicked_at, session_id FROM bifrost_joins ORDER BY clicked_at DESC`
|
||||
),
|
||||
listJoinsForEmail: db.prepare(
|
||||
`SELECT id, clicked_at, session_id FROM bifrost_joins WHERE email = ? ORDER BY clicked_at DESC`
|
||||
),
|
||||
// Per-user summary: most recent click first, with per-user counts.
|
||||
summariseJoins: db.prepare(
|
||||
`SELECT email,
|
||||
COUNT(*) AS click_count,
|
||||
MIN(clicked_at) AS first_clicked_at,
|
||||
MAX(clicked_at) AS last_clicked_at
|
||||
FROM bifrost_joins
|
||||
GROUP BY email
|
||||
ORDER BY last_clicked_at DESC`
|
||||
),
|
||||
countJoins: db.prepare(`SELECT COUNT(*) AS n FROM bifrost_joins`),
|
||||
countUniqueJoiners: db.prepare(`SELECT COUNT(DISTINCT email) AS n FROM bifrost_joins`),
|
||||
|
||||
// cleanup
|
||||
cleanup: {
|
||||
sessions: db.prepare('DELETE FROM sessions WHERE expires_at < ?'),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue