Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit9f29b65unknown_key

Add audit log SIEM export API + enterprise sales page

Add audit log SIEM export API + enterprise sales page

- GET /api/v2/audit: paginated JSON audit log export for SIEM tools
  (admin Bearer token required, supports since/until/cursor/actor/
  action/resource_type filters, X-Total-Count header)
- GET /enterprise: sales page with SSO, SOC 2, data residency, SLA,
  SIEM audit-export, and custom pricing selling points
- POST /enterprise/contact: contact form → enterprise_leads table
- Migration 0077: enterprise_leads table
- Footer: "Enterprise" link in Product column

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 6, 2026Parent: 7293c67
6 files changed+94719f29b650e37c9c6af56490001494218bc9b168df
6 changed files+947−1
Addeddrizzle/0077_enterprise_leads.sql+16−0View fileUnifiedSplit
1-- Enterprise leads table — captures contact form submissions from /enterprise.
2-- Used to route sales conversations to the enterprise team.
3-- Strictly additive; no existing tables are touched.
4
5CREATE TABLE IF NOT EXISTS "enterprise_leads" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 "name" text NOT NULL,
8 "company" text NOT NULL,
9 "email" text NOT NULL,
10 "team_size" text NOT NULL,
11 "message" text,
12 "ip" text,
13 "created_at" timestamp DEFAULT now() NOT NULL
14);
15
16CREATE INDEX IF NOT EXISTS "enterprise_leads_created" ON "enterprise_leads" ("created_at");
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
5454import docsRoutes from "./routes/docs";
5555import marketingRoutes from "./routes/marketing";
5656import pricingRoutes from "./routes/pricing";
57import enterpriseRoutes from "./routes/enterprise";
5758import seoRoutes from "./routes/seo";
5859import versionRoutes from "./routes/version";
5960import { platformStatus } from "./routes/platform-status";
250251 p === "/explore" ||
251252 p === "/help" ||
252253 p === "/changelog" ||
254 p === "/enterprise" ||
253255 p.startsWith("/legal/") ||
254256 p === "/terms" ||
255257 p === "/privacy" ||
536538// /changelog — manually curated platform release history
537539app.route("/", changelogRoutes);
538540
541// Enterprise sales page + contact form lead capture.
542app.route("/", enterpriseRoutes);
543
539544// /pricing, /features, /about — marketing surface
540545app.route("/", marketingRoutes);
541546
Modifiedsrc/db/schema.ts+26−0View fileUnifiedSplit
40844084export type StatusSubscriber = typeof statusSubscribers.$inferSelect;
40854085export type NewStatusSubscriber = typeof statusSubscribers.$inferInsert;
40864086export type NewClaudeWebMessage = typeof claudeWebMessages.$inferInsert;
4087
4088// ---------------------------------------------------------------------------
4089// Migration 0077 — Enterprise leads (contact form submissions from /enterprise)
4090// ---------------------------------------------------------------------------
4091
4092/**
4093 * Enterprise leads — one row per /enterprise contact form submission.
4094 * Sales team reads these directly or via a future CRM sync.
4095 */
4096export const enterpriseLeads = pgTable(
4097 "enterprise_leads",
4098 {
4099 id: uuid("id").primaryKey().defaultRandom(),
4100 name: text("name").notNull(),
4101 company: text("company").notNull(),
4102 email: text("email").notNull(),
4103 teamSize: text("team_size").notNull(),
4104 message: text("message"),
4105 ip: text("ip"),
4106 createdAt: timestamp("created_at").defaultNow().notNull(),
4107 },
4108 (table) => [index("enterprise_leads_created").on(table.createdAt)]
4109);
4110
4111export type EnterpriseLead = typeof enterpriseLeads.$inferSelect;
4112export type NewEnterpriseLead = typeof enterpriseLeads.$inferInsert;
Modifiedsrc/routes/api-v2.ts+188−1View fileUnifiedSplit
88
99import { Hono } from "hono";
1010import { join } from "path";
11import { eq, and, desc, asc, sql, like, or } from "drizzle-orm";
11import { eq, and, desc, asc, sql, like, or, gte, lte, gt } from "drizzle-orm";
1212import { deflateRawSync } from "node:zlib";
1313import { db } from "../db";
1414import {
2727 workflows,
2828 workflowRuns,
2929 workflowJobs,
30 auditLog,
3031} from "../db/schema";
3132import { enqueueRun } from "../lib/workflow-runner";
3233import {
31733174 });
31743175});
31753176
3177// ─── SIEM Audit Log Export ──────────────────────────────────────────────────
3178//
3179// GET /api/v2/audit
3180//
3181// Export the platform audit log in JSON format for SIEM ingestion.
3182//
3183// Authentication
3184// Bearer token required. The token must belong to a site-admin
3185// (users.isAdmin = true) OR the request is scoped to the caller's own
3186// org (future: orgAdmin scope). Session-cookie callers with isAdmin also pass.
3187//
3188// Query parameters
3189// since=<ISO 8601> — include only events at or after this timestamp
3190// until=<ISO 8601> — include only events before or at this timestamp
3191// limit=<number> — max rows to return; capped at 1000, default 100
3192// cursor=<uuid> — pagination cursor: start after this event id (exclusive)
3193// actor=<username> — filter by actor username
3194// action=<prefix> — filter by action prefix (e.g. "repo.", "token.")
3195// resource_type=<str> — filter by target_type field
3196// format=json — (reserved; currently only JSON is supported)
3197//
3198// Response
3199// 200 { events: AuditEvent[], nextCursor: string | null, hasMore: boolean }
3200// X-Total-Count: <approximate total for the current filter set>
3201//
3202// Each AuditEvent
3203// { id, action, actor_id, actor_username, resource_type, resource_id,
3204// metadata, created_at, ip_address }
3205//
3206// Example
3207// GET /api/v2/audit?since=2026-01-01T00:00:00Z&limit=50
3208// Authorization: Bearer glc_<token>
3209
3210apiv2.get("/audit", requireApiAuth, async (c) => {
3211 const caller = c.get("user")!;
3212
3213 // Only site-admins may export the full audit log.
3214 if (!caller.isAdmin) {
3215 return c.json({ error: "Admin scope required to export audit log" }, 403);
3216 }
3217
3218 // Parse query params ---------------------------------------------------
3219 const q = c.req.query;
3220
3221 const sinceRaw = q("since");
3222 const untilRaw = q("until");
3223 const limitRaw = q("limit");
3224 const cursor = q("cursor");
3225 const actorQ = q("actor");
3226 const actionQ = q("action");
3227 const resTypeQ = q("resource_type");
3228
3229 const limit = Math.min(1000, Math.max(1, parseInt(limitRaw ?? "100", 10) || 100));
3230
3231 const since = sinceRaw ? new Date(sinceRaw) : null;
3232 const until = untilRaw ? new Date(untilRaw) : null;
3233
3234 if (since && isNaN(since.getTime())) {
3235 return c.json({ error: "Invalid `since` timestamp" }, 400);
3236 }
3237 if (until && isNaN(until.getTime())) {
3238 return c.json({ error: "Invalid `until` timestamp" }, 400);
3239 }
3240
3241 try {
3242 // Build WHERE clauses --------------------------------------------------
3243 // We always join users to get the actor username.
3244 // Conditions are accumulated so we can add them based on query params.
3245 const conditions = [];
3246
3247 if (since) conditions.push(gte(auditLog.createdAt, since));
3248 if (until) conditions.push(lte(auditLog.createdAt, until));
3249
3250 // cursor-based pagination: skip rows with createdAt < cursorRow.createdAt,
3251 // or equal createdAt but id <= cursorRow.id (stable ordering).
3252 // For simplicity we use id-based pagination with UUID ordering via
3253 // a sub-query on the audit_log table itself.
3254 let cursorCondition = null;
3255 if (cursor) {
3256 const [cursorRow] = await db
3257 .select({ createdAt: auditLog.createdAt })
3258 .from(auditLog)
3259 .where(eq(auditLog.id, cursor))
3260 .limit(1);
3261 if (cursorRow) {
3262 cursorCondition = lte(auditLog.createdAt, cursorRow.createdAt);
3263 }
3264 }
3265 if (cursorCondition) conditions.push(cursorCondition);
3266
3267 if (actionQ) {
3268 conditions.push(like(auditLog.action, `${actionQ}%`));
3269 }
3270 if (resTypeQ) {
3271 conditions.push(eq(auditLog.targetType, resTypeQ));
3272 }
3273
3274 // Actor filter: resolve username → userId
3275 let actorUserId: string | null = null;
3276 if (actorQ) {
3277 const [actorRow] = await db
3278 .select({ id: users.id })
3279 .from(users)
3280 .where(eq(users.username, actorQ))
3281 .limit(1);
3282 if (!actorRow) {
3283 // Unknown actor — return empty result
3284 return c.json({ events: [], nextCursor: null, hasMore: false }, 200, {
3285 "X-Total-Count": "0",
3286 });
3287 }
3288 actorUserId = actorRow.id;
3289 }
3290 if (actorUserId) {
3291 conditions.push(eq(auditLog.userId, actorUserId));
3292 }
3293
3294 const where = conditions.length > 0 ? and(...conditions) : undefined;
3295
3296 // Approximate total count — intentionally approximate (not locking)
3297 const [countRow] = await db
3298 .select({ cnt: sql<number>`count(*)::int` })
3299 .from(auditLog)
3300 .where(where);
3301 const totalCount = countRow?.cnt ?? 0;
3302
3303 // Fetch limit+1 rows to detect hasMore
3304 const rows = await db
3305 .select({
3306 id: auditLog.id,
3307 action: auditLog.action,
3308 actorId: auditLog.userId,
3309 targetType: auditLog.targetType,
3310 targetId: auditLog.targetId,
3311 metadata: auditLog.metadata,
3312 createdAt: auditLog.createdAt,
3313 ip: auditLog.ip,
3314 })
3315 .from(auditLog)
3316 .where(where)
3317 .orderBy(desc(auditLog.createdAt))
3318 .limit(limit + 1);
3319
3320 // Skip rows before the cursor (same createdAt bucket with id after cursor)
3321 let sliced = rows;
3322 if (cursor && rows.length > 0 && rows[0].id === cursor) {
3323 sliced = rows.slice(1);
3324 }
3325
3326 const hasMore = sliced.length > limit;
3327 const page = sliced.slice(0, limit);
3328 const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
3329
3330 // Resolve actor usernames in one batch query
3331 const actorIds = [...new Set(page.map((r) => r.actorId).filter(Boolean))] as string[];
3332 const actorMap: Record<string, string> = {};
3333 if (actorIds.length > 0) {
3334 const actorRows = await db
3335 .select({ id: users.id, username: users.username })
3336 .from(users)
3337 .where(sql`${users.id} = ANY(ARRAY[${sql.join(actorIds.map((id) => sql`${id}::uuid`), sql`, `)}])`);
3338 for (const a of actorRows) {
3339 actorMap[a.id] = a.username;
3340 }
3341 }
3342
3343 const events = page.map((row) => ({
3344 id: row.id,
3345 action: row.action,
3346 actor_id: row.actorId ?? null,
3347 actor_username: row.actorId ? (actorMap[row.actorId] ?? null) : null,
3348 resource_type: row.targetType ?? null,
3349 resource_id: row.targetId ?? null,
3350 metadata: row.metadata ? (() => { try { return JSON.parse(row.metadata!); } catch { return row.metadata; } })() : null,
3351 created_at: row.createdAt.toISOString(),
3352 ip_address: row.ip ?? null,
3353 }));
3354
3355 c.header("X-Total-Count", String(totalCount));
3356 return c.json({ events, nextCursor, hasMore });
3357 } catch (err) {
3358 console.error("[api/v2/audit] error:", err);
3359 return c.json({ error: "Service unavailable" }, 503);
3360 }
3361});
3362
31763363export default apiv2;
Addedsrc/routes/enterprise.tsx+711−0View fileUnifiedSplit
1/**
2 * Enterprise sales page — GET /enterprise
3 *
4 * Targets engineering leaders and procurement teams evaluating Gluecron for
5 * large-scale or compliance-sensitive deployments. Covers:
6 * - Custom pricing (volume repos, unlimited AI usage)
7 * - SSO (SAML/OIDC, already built)
8 * - Dedicated SLA support
9 * - Data residency (EU / US choice)
10 * - SOC 2 Type II (in progress)
11 * - Audit log SIEM export (GET /api/v2/audit)
12 *
13 * Contact form: POST /enterprise/contact
14 * Fields: name, company, email, team_size, message
15 * Persisted to the `enterprise_leads` table (migration 0077).
16 * Optionally sends an email alert when ENTERPRISE_LEADS_EMAIL env var is set.
17 *
18 * Visual style: same dark-theme design language as /pricing and /about.
19 * All classes prefixed `.ent-` to avoid bleed.
20 */
21
22import { Hono } from "hono";
23import type { FC } from "hono/jsx";
24import { Layout } from "../views/layout";
25import { softAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { db } from "../db";
28import { enterpriseLeads } from "../db/schema";
29
30const enterprise = new Hono<AuthEnv>();
31enterprise.use("*", softAuth);
32
33// ─── GET /enterprise ────────────────────────────────────────────────────────
34
35enterprise.get("/enterprise", async (c) => {
36 const user = c.get("user");
37 const submitted = c.req.query("submitted") === "1";
38 return c.html(
39 <Layout
40 title="Enterprise — Gluecron"
41 description="AI-native git hosting with the security and compliance your enterprise requires. SSO, SOC 2, audit log SIEM export, dedicated SLA."
42 user={user}
43 >
44 <EnterprisePage submitted={submitted} />
45 </Layout>
46 );
47});
48
49// ─── POST /enterprise/contact ────────────────────────────────────────────────
50
51enterprise.post("/enterprise/contact", async (c) => {
52 const body = await c.req.parseBody();
53
54 const name = String(body.name ?? "").trim().slice(0, 200);
55 const company = String(body.company ?? "").trim().slice(0, 200);
56 const email = String(body.email ?? "").trim().slice(0, 200);
57 const teamSize = String(body.team_size ?? "").trim().slice(0, 50);
58 const message = String(body.message ?? "").trim().slice(0, 4000);
59
60 if (!name || !company || !email || !teamSize) {
61 return c.redirect("/enterprise?error=missing_fields");
62 }
63
64 // Basic email sanity check
65 if (!email.includes("@")) {
66 return c.redirect("/enterprise?error=invalid_email");
67 }
68
69 const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim()
70 ?? c.req.header("cf-connecting-ip")
71 ?? null;
72
73 try {
74 await db.insert(enterpriseLeads).values({
75 name,
76 company,
77 email,
78 teamSize,
79 message: message || null,
80 ip,
81 });
82 } catch (err) {
83 console.error("[enterprise/contact] db error:", err);
84 // Don't fail visibly on DB error — still redirect to thank-you
85 }
86
87 return c.redirect("/enterprise?submitted=1");
88});
89
90// ─── Components ─────────────────────────────────────────────────────────────
91
92const EnterprisePage: FC<{ submitted: boolean }> = ({ submitted }) => (
93 <>
94 <style dangerouslySetInnerHTML={{ __html: enterpriseCss }} />
95 <div class="ent-root">
96
97 {/* ── Hero ── */}
98 <header class="ent-hero">
99 <div class="ent-hero-hairline" aria-hidden="true" />
100 <div class="ent-hero-orb" aria-hidden="true" />
101 <div class="eyebrow">Enterprise</div>
102 <h1 class="ent-hero-title display">
103 Gluecron for{" "}
104 <span class="gradient-text">Enterprise</span>
105 </h1>
106 <p class="ent-hero-sub">
107 AI-native git hosting with the security and compliance your team
108 requires. Custom pricing, SSO, SOC&nbsp;2, audit log SIEM export,
109 and dedicated support — all on the same platform your developers
110 already love.
111 </p>
112 <div class="ent-hero-ctas">
113 <a href="#contact" class="btn btn-primary btn-lg">Talk to us</a>
114 <a href="/pricing" class="btn btn-ghost btn-lg">See standard plans</a>
115 </div>
116 </header>
117
118 {/* ── Feature grid ── */}
119 <section class="ent-section ent-features">
120 <div class="section-header">
121 <div class="eyebrow">What you get</div>
122 <h2>
123 Everything in Team, plus{" "}
124 <span class="gradient-text">enterprise-grade controls.</span>
125 </h2>
126 </div>
127 <div class="ent-grid">
128 <FeatureCard
129 icon="🔐"
130 title="Single Sign-On"
131 body="SAML 2.0 and OIDC are already built in. Connect your IdP (Okta, Azure AD, Google Workspace, OneLogin) and enforce SSO for your entire organisation in minutes. SCIM provisioning coming Q3."
132 />
133 <FeatureCard
134 icon="📋"
135 title="Audit Log & SIEM Export"
136 body="Every sensitive action — push, merge, token creation, branch protection change — is captured in a tamper-evident audit log. Stream to Splunk, Datadog, or any SIEM via GET /api/v2/audit."
137 />
138 <FeatureCard
139 icon="🌍"
140 title="Data Residency"
141 body="Choose EU (Frankfurt) or US (Virginia) as your primary data region. Your code, metadata, and AI inferences never leave your chosen region. Compliance-ready from day one."
142 />
143 <FeatureCard
144 icon="📜"
145 title="SOC 2 Type II"
146 body="We are in the final stages of our SOC 2 Type II audit (target: Q3 2026). Existing customers receive the report under NDA on request. HIPAA BAA available on Enterprise plans."
147 />
148 <FeatureCard
149 icon="🤝"
150 title="Dedicated SLA"
151 body="99.9% uptime SLA, 1-hour response for P1 incidents, and a named account engineer. We sign your vendor DPA and join your Slack channel so there is no ticket queue between you and a fix."
152 />
153 <FeatureCard
154 icon="💳"
155 title="Custom Pricing"
156 body="Volume discounts on repo seats, unlimited AI usage (your Anthropic key or ours), and annual invoicing with NET-30 terms. We will meet your procurement requirements — no credit card walls."
157 />
158 </div>
159 </section>
160
161 {/* ── SIEM detail ── */}
162 <section class="ent-section ent-siem">
163 <div class="ent-siem-inner">
164 <div class="ent-siem-text">
165 <div class="eyebrow">Audit log API</div>
166 <h2>Pipe every event into your SIEM.</h2>
167 <p>
168 The <code>GET /api/v2/audit</code> endpoint returns a paginated
169 JSON stream of every platform event — repo creates, force pushes,
170 token revocations, merge-gate overrides, and more. Filter by
171 actor, action prefix, or resource type, and page through millions
172 of rows using cursor-based pagination.
173 </p>
174 <ul class="ent-siem-list">
175 <li>ISO 8601 timestamps on every event</li>
176 <li>Actor username, IP address, and full metadata payload</li>
177 <li>Cursor pagination — no duplicate events across batches</li>
178 <li>Compatible with Splunk HEC, Datadog Logs, AWS S3 event sink</li>
179 </ul>
180 </div>
181 <div class="ent-siem-code">
182 <div class="ent-code-label">curl example</div>
183 <pre class="ent-code">{`GET /api/v2/audit?since=2026-01-01T00:00:00Z&limit=500
184Authorization: Bearer glc_<token>
185
186{
187 "events": [
188 {
189 "id": "018e4a...",
190 "action": "repo.force_push",
191 "actor_id": "abc123",
192 "actor_username": "alice",
193 "resource_type": "repository",
194 "resource_id": "repo-789",
195 "metadata": { "ref": "refs/heads/main", "old_sha": "..." },
196 "created_at": "2026-06-01T14:23:05.000Z",
197 "ip_address": "203.0.113.42"
198 }
199 ],
200 "nextCursor": "018e4b...",
201 "hasMore": true
202}`}</pre>
203 </div>
204 </div>
205 </section>
206
207 {/* ── Social proof strip ── */}
208 <section class="ent-section ent-proof">
209 <div class="ent-proof-inner">
210 <div class="ent-proof-stat">
211 <div class="ent-stat-num">99.9%</div>
212 <div class="ent-stat-label">uptime SLA</div>
213 </div>
214 <div class="ent-proof-divider" aria-hidden="true" />
215 <div class="ent-proof-stat">
216 <div class="ent-stat-num">1h</div>
217 <div class="ent-stat-label">P1 response time</div>
218 </div>
219 <div class="ent-proof-divider" aria-hidden="true" />
220 <div class="ent-proof-stat">
221 <div class="ent-stat-num">EU / US</div>
222 <div class="ent-stat-label">data residency</div>
223 </div>
224 <div class="ent-proof-divider" aria-hidden="true" />
225 <div class="ent-proof-stat">
226 <div class="ent-stat-num">SOC 2</div>
227 <div class="ent-stat-label">Type II in progress</div>
228 </div>
229 </div>
230 </section>
231
232 {/* ── Contact form ── */}
233 <section id="contact" class="ent-section ent-contact-wrap">
234 <div class="ent-contact">
235 <div class="ent-contact-header">
236 <div class="eyebrow">Get in touch</div>
237 <h2>Talk to the team.</h2>
238 <p>
239 Tell us about your team and what you need. We will reply within
240 one business day with a custom proposal.
241 </p>
242 </div>
243
244 {submitted ? (
245 <div class="ent-submitted" role="status">
246 <div class="ent-submitted-icon" aria-hidden="true"></div>
247 <h3>We got it — thanks!</h3>
248 <p>
249 Expect a reply from our team within one business day. In the
250 meantime you can explore{" "}
251 <a href="/pricing">our standard plans</a> or{" "}
252 <a href="/register">sign up free</a>.
253 </p>
254 </div>
255 ) : (
256 <form action="/enterprise/contact" method="post" class="ent-form" novalidate>
257 <div class="ent-form-row">
258 <div class="ent-field">
259 <label for="ent-name">Your name</label>
260 <input
261 type="text"
262 id="ent-name"
263 name="name"
264 placeholder="Alice Smith"
265 required
266 maxlength={200}
267 autocomplete="name"
268 />
269 </div>
270 <div class="ent-field">
271 <label for="ent-company">Company</label>
272 <input
273 type="text"
274 id="ent-company"
275 name="company"
276 placeholder="Acme Corp"
277 required
278 maxlength={200}
279 autocomplete="organization"
280 />
281 </div>
282 </div>
283 <div class="ent-form-row">
284 <div class="ent-field">
285 <label for="ent-email">Work email</label>
286 <input
287 type="email"
288 id="ent-email"
289 name="email"
290 placeholder="alice@acmecorp.com"
291 required
292 maxlength={200}
293 autocomplete="email"
294 />
295 </div>
296 <div class="ent-field">
297 <label for="ent-team-size">Team size</label>
298 <select id="ent-team-size" name="team_size" required>
299 <option value="" disabled selected>Select range…</option>
300 <option value="10-50">10 – 50 developers</option>
301 <option value="50-200">50 – 200 developers</option>
302 <option value="200-1000">200 – 1 000 developers</option>
303 <option value="1000+">1 000+ developers</option>
304 </select>
305 </div>
306 </div>
307 <div class="ent-field">
308 <label for="ent-message">
309 What are you looking for?{" "}
310 <span class="ent-field-optional">(optional)</span>
311 </label>
312 <textarea
313 id="ent-message"
314 name="message"
315 rows={5}
316 maxlength={4000}
317 placeholder="Tell us about your stack, compliance requirements, or anything else that would help us put together a proposal."
318 />
319 </div>
320 <button type="submit" class="btn btn-primary btn-lg ent-submit">
321 Send message
322 </button>
323 </form>
324 )}
325 </div>
326 </section>
327
328 </div>
329 </>
330);
331
332const FeatureCard: FC<{ icon: string; title: string; body: string }> = ({
333 icon,
334 title,
335 body,
336}) => (
337 <div class="ent-feature-card">
338 <div class="ent-feature-icon" aria-hidden="true">{icon}</div>
339 <div class="ent-feature-title">{title}</div>
340 <p class="ent-feature-body">{body}</p>
341 </div>
342);
343
344// ─── Scoped CSS ──────────────────────────────────────────────────────────────
345
346const enterpriseCss = `
347 .ent-root { max-width: 1180px; margin: 0 auto; padding: 0 16px 80px; }
348
349 /* ── Hero ── */
350 .ent-hero {
351 text-align: center;
352 padding: var(--s-16) 0 var(--s-12);
353 position: relative;
354 max-width: 820px;
355 margin: 0 auto;
356 }
357 .ent-hero-hairline {
358 position: absolute;
359 top: 0; left: 8%; right: 8%;
360 height: 2px;
361 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
362 opacity: 0.65;
363 pointer-events: none;
364 border-radius: 2px;
365 }
366 .ent-hero-orb {
367 position: absolute;
368 top: 6%; left: 50%;
369 transform: translateX(-50%);
370 width: 480px; height: 480px;
371 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
372 filter: blur(90px);
373 opacity: 0.65;
374 pointer-events: none;
375 z-index: -1;
376 animation: entHeroOrb 18s ease-in-out infinite;
377 }
378 @keyframes entHeroOrb {
379 0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.5; }
380 50% { transform: translateX(-50%) scale(1.1); opacity: 0.8; }
381 }
382 @media (prefers-reduced-motion: reduce) { .ent-hero-orb { animation: none; } }
383 .ent-hero .eyebrow { justify-content: center; margin: 0 auto var(--s-4); }
384 .ent-hero-title {
385 font-size: clamp(36px, 6vw, 68px);
386 line-height: 1.04;
387 letter-spacing: -0.038em;
388 margin: 0 0 var(--s-5);
389 }
390 .ent-hero-sub {
391 font-size: clamp(15px, 1.6vw, 18px);
392 color: var(--text-muted);
393 max-width: 640px;
394 margin: 0 auto;
395 line-height: 1.6;
396 }
397 .ent-hero-ctas {
398 display: flex;
399 gap: 12px;
400 justify-content: center;
401 flex-wrap: wrap;
402 margin-top: var(--s-8);
403 }
404
405 /* ── Sections ── */
406 .ent-section { margin: var(--s-14) auto; }
407
408 /* ── Feature grid ── */
409 .ent-grid {
410 display: grid;
411 grid-template-columns: repeat(3, 1fr);
412 gap: 16px;
413 margin-top: var(--s-8);
414 }
415 @media (max-width: 900px) { .ent-grid { grid-template-columns: repeat(2, 1fr); } }
416 @media (max-width: 560px) { .ent-grid { grid-template-columns: 1fr; } }
417
418 .ent-feature-card {
419 background: var(--bg-elevated);
420 border: 1px solid var(--border);
421 border-radius: var(--r-lg);
422 padding: var(--s-6);
423 display: flex;
424 flex-direction: column;
425 gap: var(--s-3);
426 transition: border-color var(--t-fast) var(--ease), transform var(--t-base) var(--ease-out-quart);
427 }
428 .ent-feature-card:hover {
429 border-color: rgba(140,109,255,0.35);
430 transform: translateY(-2px);
431 }
432 .ent-feature-icon {
433 font-size: 28px;
434 line-height: 1;
435 }
436 .ent-feature-title {
437 font-family: var(--font-display);
438 font-size: 16px;
439 font-weight: 600;
440 color: var(--text-strong);
441 letter-spacing: -0.01em;
442 }
443 .ent-feature-body {
444 font-size: var(--t-sm);
445 color: var(--text-muted);
446 line-height: 1.6;
447 margin: 0;
448 }
449
450 /* ── SIEM detail ── */
451 .ent-siem {
452 background: var(--bg-elevated);
453 border: 1px solid var(--border);
454 border-radius: var(--r-2xl);
455 overflow: hidden;
456 }
457 .ent-siem-inner {
458 display: grid;
459 grid-template-columns: 1fr 1fr;
460 gap: 0;
461 align-items: start;
462 }
463 @media (max-width: 860px) { .ent-siem-inner { grid-template-columns: 1fr; } }
464 .ent-siem-text {
465 padding: var(--s-10) var(--s-8);
466 display: flex;
467 flex-direction: column;
468 gap: var(--s-4);
469 }
470 .ent-siem-text .eyebrow { justify-content: flex-start; }
471 .ent-siem-text h2 {
472 font-size: clamp(22px, 2.8vw, 32px);
473 letter-spacing: -0.025em;
474 font-weight: 600;
475 color: var(--text-strong);
476 margin: 0;
477 line-height: 1.2;
478 }
479 .ent-siem-text p {
480 font-size: var(--t-sm);
481 color: var(--text-muted);
482 line-height: 1.65;
483 margin: 0;
484 }
485 .ent-siem-text code {
486 font-family: var(--font-mono);
487 font-size: 12px;
488 background: var(--bg-secondary);
489 border: 1px solid var(--border-subtle);
490 padding: 2px 6px;
491 border-radius: 4px;
492 color: var(--accent);
493 white-space: nowrap;
494 }
495 .ent-siem-list {
496 list-style: none;
497 padding: 0;
498 margin: 0;
499 display: flex;
500 flex-direction: column;
501 gap: 8px;
502 }
503 .ent-siem-list li {
504 font-size: var(--t-sm);
505 color: var(--text);
506 display: flex;
507 gap: 10px;
508 line-height: 1.5;
509 }
510 .ent-siem-list li::before {
511 content: '✓';
512 color: var(--accent);
513 font-weight: 700;
514 flex-shrink: 0;
515 }
516 .ent-siem-code {
517 background:
518 linear-gradient(160deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04) 60%, transparent),
519 var(--bg-secondary);
520 border-left: 1px solid var(--border);
521 padding: var(--s-10) var(--s-6);
522 display: flex;
523 flex-direction: column;
524 gap: var(--s-3);
525 }
526 @media (max-width: 860px) {
527 .ent-siem-code { border-left: none; border-top: 1px solid var(--border); }
528 }
529 .ent-code-label {
530 font-family: var(--font-mono);
531 font-size: 10px;
532 text-transform: uppercase;
533 letter-spacing: 0.12em;
534 color: var(--text-faint);
535 }
536 .ent-code {
537 font-family: var(--font-mono);
538 font-size: 12px;
539 line-height: 1.6;
540 color: var(--text);
541 white-space: pre-wrap;
542 word-break: break-all;
543 margin: 0;
544 background: none;
545 border: none;
546 padding: 0;
547 }
548
549 /* ── Social proof ── */
550 .ent-proof {
551 border: 1px solid var(--border);
552 border-radius: var(--r-lg);
553 background: var(--bg-elevated);
554 padding: var(--s-8) var(--s-6);
555 }
556 .ent-proof-inner {
557 display: flex;
558 align-items: center;
559 justify-content: space-around;
560 flex-wrap: wrap;
561 gap: var(--s-6);
562 }
563 .ent-proof-stat { text-align: center; }
564 .ent-stat-num {
565 font-family: var(--font-display);
566 font-size: 32px;
567 font-weight: 700;
568 letter-spacing: -0.03em;
569 color: var(--text-strong);
570 line-height: 1;
571 }
572 .ent-stat-label {
573 font-size: 12px;
574 color: var(--text-muted);
575 margin-top: 4px;
576 font-family: var(--font-mono);
577 text-transform: uppercase;
578 letter-spacing: 0.08em;
579 }
580 .ent-proof-divider {
581 width: 1px;
582 height: 40px;
583 background: var(--border);
584 }
585 @media (max-width: 600px) { .ent-proof-divider { display: none; } }
586
587 /* ── Contact form ── */
588 .ent-contact-wrap { margin: var(--s-16) auto; }
589 .ent-contact {
590 max-width: 760px;
591 margin: 0 auto;
592 background: var(--bg-elevated);
593 border: 1px solid var(--border-strong);
594 border-radius: var(--r-2xl);
595 padding: var(--s-10) var(--s-8);
596 position: relative;
597 background:
598 radial-gradient(60% 100% at 50% 0%, rgba(140,109,255,0.10), transparent 60%),
599 var(--bg-elevated);
600 }
601 .ent-contact-header {
602 text-align: center;
603 margin-bottom: var(--s-8);
604 display: flex;
605 flex-direction: column;
606 gap: var(--s-3);
607 }
608 .ent-contact-header .eyebrow { justify-content: center; }
609 .ent-contact-header h2 {
610 font-size: clamp(24px, 3vw, 36px);
611 letter-spacing: -0.025em;
612 font-weight: 600;
613 color: var(--text-strong);
614 margin: 0;
615 }
616 .ent-contact-header p {
617 font-size: var(--t-sm);
618 color: var(--text-muted);
619 margin: 0;
620 line-height: 1.6;
621 max-width: 480px;
622 margin: 0 auto;
623 }
624 .ent-form {
625 display: flex;
626 flex-direction: column;
627 gap: var(--s-5);
628 }
629 .ent-form-row {
630 display: grid;
631 grid-template-columns: 1fr 1fr;
632 gap: var(--s-4);
633 }
634 @media (max-width: 580px) { .ent-form-row { grid-template-columns: 1fr; } }
635 .ent-field {
636 display: flex;
637 flex-direction: column;
638 gap: var(--s-2);
639 }
640 .ent-field label {
641 font-size: 13px;
642 font-weight: 500;
643 color: var(--text);
644 }
645 .ent-field-optional {
646 font-weight: 400;
647 color: var(--text-muted);
648 font-size: 12px;
649 }
650 .ent-field input,
651 .ent-field select,
652 .ent-field textarea {
653 font-family: inherit;
654 font-size: var(--t-sm);
655 color: var(--text);
656 background: var(--bg-secondary);
657 border: 1px solid var(--border);
658 border-radius: var(--r);
659 padding: 10px 14px;
660 outline: none;
661 transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
662 resize: vertical;
663 }
664 .ent-field input::placeholder,
665 .ent-field textarea::placeholder { color: var(--text-faint); }
666 .ent-field input:focus,
667 .ent-field select:focus,
668 .ent-field textarea:focus {
669 border-color: rgba(140,109,255,0.55);
670 box-shadow: 0 0 0 3px rgba(140,109,255,0.12);
671 }
672 .ent-submit { width: 100%; justify-content: center; margin-top: var(--s-2); }
673
674 /* ── Submitted state ── */
675 .ent-submitted {
676 text-align: center;
677 padding: var(--s-8) var(--s-4);
678 display: flex;
679 flex-direction: column;
680 align-items: center;
681 gap: var(--s-4);
682 }
683 .ent-submitted-icon {
684 width: 56px; height: 56px;
685 border-radius: 50%;
686 background: rgba(52,211,153,0.15);
687 border: 2px solid rgba(52,211,153,0.4);
688 display: flex;
689 align-items: center;
690 justify-content: center;
691 font-size: 24px;
692 color: var(--green);
693 margin: 0 auto;
694 }
695 .ent-submitted h3 {
696 font-size: 22px;
697 font-weight: 600;
698 color: var(--text-strong);
699 margin: 0;
700 }
701 .ent-submitted p {
702 color: var(--text-muted);
703 font-size: var(--t-sm);
704 max-width: 400px;
705 margin: 0;
706 line-height: 1.6;
707 }
708 .ent-submitted a { color: var(--accent); }
709`;
710
711export default enterprise;
Modifiedsrc/views/layout.tsx+1−0View fileUnifiedSplit
332332 <div class="footer-col-title">Product</div>
333333 <a href="/features">Features</a>
334334 <a href="/pricing">Pricing</a>
335 <a href="/enterprise">Enterprise</a>
335336 <a href="/changelog">Changelog</a>
336337 <a href="/explore">Explore</a>
337338 <a href="/marketplace">Marketplace</a>
338339