diff --git a/.gitignore b/.gitignore
index fb76ec44..0d85cf8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ node_modules
out
src/videos/**/out
dist
+dist_*
.dist
runtime-scripts
*.tgz
diff --git a/package.json b/package.json
index 173ba0b6..daa45cff 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"build:editor:app": "bun run ./scripts/build/editor.ts --app",
"build:editor:extension": "bun run ./scripts/build/editor.ts --extension",
"build:editor:runtime": "bun run ./scripts/build/editor.ts --runtime",
+ "build:landing": "bun run ./scripts/build/landing.ts",
"db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "cd src/databases/officer_db && bun run push",
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
diff --git a/scripts/build/landing.ts b/scripts/build/landing.ts
index daf69df1..dab79f37 100644
--- a/scripts/build/landing.ts
+++ b/scripts/build/landing.ts
@@ -1,33 +1,22 @@
#!/usr/bin/env bun
import plugin from 'bun-plugin-tailwind';
-import { config as dotenv } from 'dotenv';
import { existsSync } from 'fs';
import { rm } from 'fs/promises';
import path from 'path';
-import { fileURLToPath } from 'url';
-import { buildConfig } from './helpers';
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const envPath = path.resolve(__dirname, '../../.env');
-dotenv({ path: envPath });
-
-const outdir = path.join(process.cwd(), 'build/landing');
+const outdir = path.join(process.cwd(), 'dist_landing');
if (existsSync(outdir)) {
console.log(`šļø Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
+console.log('\nš Starting landing page build...\n');
+
const start = performance.now();
-const configPath = 'src/workspaces/config/src/index.ts';
-if (existsSync(configPath)) {
- console.log('š§ Generating config with environment values...');
- buildConfig(configPath, 'landing');
-}
-
-const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/officer-web')]
- .map((a) => path.resolve('src/apps/officer-web', a))
+const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/landing')]
+ .map((a) => path.resolve('src/apps/landing', a))
.filter((dir) => !dir.includes('node_modules'));
console.log(`š Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`);
@@ -35,12 +24,10 @@ const formatFileSize = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
-
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
-
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
@@ -65,6 +52,4 @@ const outputTable = result.outputs.map((output) => ({
}));
console.table(outputTable);
-const buildTime = (end - start).toFixed(2);
-
-console.log(`\nā
Build completed in ${buildTime}ms\n`);
+console.log(`\nā
Build completed in ${(end - start).toFixed(2)}ms\n`);
diff --git a/src/apps/landing/App.tsx b/src/apps/landing/App.tsx
new file mode 100644
index 00000000..60a7a7da
--- /dev/null
+++ b/src/apps/landing/App.tsx
@@ -0,0 +1,48 @@
+import { PixelGrid } from '../../workspaces/components/PixelGrid';
+import { DuckAvatar } from './DuckAvatar';
+import { WaitlistForm } from './WaitlistForm';
+
+const landscapeBg = 'https://static.officer.dev/landscape1.webp';
+const logoSrc = 'https://static.officer.dev/officer-logo.svg';
+
+export const App = () => {
+ return (
+
+ {/* Background */}
+
+
+ {/* Logo */}
+
+
+

+
+
+

+
+
+
+ {/* Waitlist form */}
+
+
+
+
+ );
+};
diff --git a/src/apps/landing/DuckAvatar.tsx b/src/apps/landing/DuckAvatar.tsx
new file mode 100644
index 00000000..e9ddb3f7
--- /dev/null
+++ b/src/apps/landing/DuckAvatar.tsx
@@ -0,0 +1,64 @@
+import { Canvas, useThree } from '@react-three/fiber';
+import { OrbitControls, useGLTF, useAnimations } from '@react-three/drei';
+import { useEffect, useState, useRef } from 'react';
+
+function DuckModel({ onLoaded }: { onLoaded: () => void }) {
+ const character = useGLTF('https://static.officer.dev/duck3D/Character_output.glb');
+ const animations = useGLTF('https://static.officer.dev/duck3D/Meshy_Merged_Animations.glb');
+ const meshRef = useRef(null);
+ const { actions } = useAnimations(animations.animations, meshRef);
+
+ useEffect(() => {
+ onLoaded();
+ }, [character, animations, onLoaded]);
+
+ useEffect(() => {
+ if (!actions) return;
+ const action = actions['Walking'];
+ if (!action) return;
+ action.reset();
+ action.setLoop(2201, Infinity);
+ action.clampWhenFinished = false;
+ action.fadeIn(0.4);
+ action.play();
+ }, [actions]);
+
+ return ;
+}
+
+function Camera() {
+ const { camera } = useThree();
+ const controlsRef = useRef(null);
+
+ useEffect(() => {
+ if (controlsRef.current) {
+ controlsRef.current.target.set(0.3, 3.29, -0.4);
+ camera.position.set(2.89, 5.24, 7.38);
+ controlsRef.current.update();
+ }
+ }, [camera]);
+
+ return ;
+}
+
+export function DuckAvatar() {
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/apps/landing/WaitlistForm.tsx b/src/apps/landing/WaitlistForm.tsx
new file mode 100644
index 00000000..6a1ccf95
--- /dev/null
+++ b/src/apps/landing/WaitlistForm.tsx
@@ -0,0 +1,117 @@
+import type { CSSProperties, FormEvent } from 'react';
+import { useEffect, useRef, useState } from 'react';
+
+const cardStyle: CSSProperties = {
+ backgroundColor: 'var(--card-bg)',
+ backgroundImage: `
+ linear-gradient(to right, var(--card-grid-color) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--card-grid-color) 1px, transparent 1px)
+ `,
+ backgroundSize: '20px 20px',
+};
+
+export function WaitlistForm() {
+ const [email, setEmail] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [done, setDone] = useState(false);
+ const [focused, setFocused] = useState(false);
+
+ const inputRef = useRef(null);
+ const wrapperRef = useRef(null);
+ const isValid = email.trim().length > 0 && email.includes('@');
+
+ useEffect(() => {
+ const vv = window.visualViewport;
+ if (!vv) return;
+
+ const reset = () => {
+ window.scrollTo(0, 0);
+ document.documentElement.scrollTop = 0;
+ document.body.scrollTop = 0;
+ };
+
+ vv.addEventListener('resize', reset);
+ vv.addEventListener('scroll', reset);
+ return () => {
+ vv.removeEventListener('resize', reset);
+ vv.removeEventListener('scroll', reset);
+ };
+ }, []);
+
+ const handleFocus = () => setFocused(true);
+
+ const handleBlur = () => {
+ setFocused(false);
+ window.scrollTo(0, 0);
+ };
+
+ const handleSubmit = async (ev: FormEvent) => {
+ ev.preventDefault();
+ inputRef.current?.blur();
+ const trimmed = email.trim();
+ if (!trimmed || isSubmitting) return;
+
+ setIsSubmitting(true);
+ try {
+ await fetch('https://alpha.officer.dev/api/waitlist', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: trimmed }),
+ });
+ setDone(true);
+ } catch {
+ setDone(true);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ {done ? (
+
+
You're on the list!
+
+
+ We'll notify {email} when Officer is ready.
+
+
+
+ ) : (
+ <>
+
Join the Waitlist
+
+ Be the first to know when Officer launches.
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/apps/landing/index.html b/src/apps/landing/index.html
new file mode 100644
index 00000000..e6ba0890
--- /dev/null
+++ b/src/apps/landing/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ Officer Dev (Alpha)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/apps/landing/main.tsx b/src/apps/landing/main.tsx
new file mode 100644
index 00000000..ed3b67f5
--- /dev/null
+++ b/src/apps/landing/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/src/apps/landing/styles.css b/src/apps/landing/styles.css
new file mode 100644
index 00000000..a20ed246
--- /dev/null
+++ b/src/apps/landing/styles.css
@@ -0,0 +1,92 @@
+@import "tailwindcss";
+@plugin 'tailwindcss-animate';
+
+@custom-variant dark (&:is(.dark *));
+
+@theme {
+ --color-duck-yellow: var(--duck-yellow);
+ --color-duck-orange: var(--duck-orange);
+ --color-duck-teal: var(--duck-teal);
+ --color-duck-forest: var(--duck-forest);
+ --color-duck-dark: var(--duck-dark);
+ --color-duck-beige: var(--duck-beige);
+
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+
+ --color-primary: hsl(var(--primary));
+ --color-primary-foreground: hsl(var(--primary-foreground));
+
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+
+ --color-input: hsl(var(--input));
+ --color-ring: hsl(var(--ring));
+
+ --radius-lg: var(--radius);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-sm: calc(var(--radius) - 4px);
+}
+
+@layer base {
+ :root {
+ --duck-yellow: #F4C430;
+ --duck-orange: #EA580C;
+ --duck-teal: #0891B2;
+ --duck-forest: #166534;
+ --duck-dark: #14532D;
+ --duck-beige: #E7D4B5;
+
+ --pixel-grid-color: rgba(20, 83, 45, 0.1);
+
+ --card-bg: rgba(255, 255, 255, 0.9);
+ --card-grid-color: rgba(20, 83, 45, 0.08);
+
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+
+ --primary: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --duck-teal: #22d3ee;
+ --duck-dark: #f1f5f9;
+ --duck-forest: #4ade80;
+ --duck-beige: #292524;
+
+ --pixel-grid-color: rgba(200, 230, 210, 0.08);
+
+ --card-bg: rgba(10, 20, 15, 0.9);
+ --card-grid-color: rgba(200, 230, 210, 0.06);
+
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
+ }
+
+ html, body {
+ @apply bg-background text-foreground;
+ margin: 0;
+ overflow: hidden;
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ }
+}
diff --git a/src/servers/api/waitlist/waitlist.ts b/src/servers/api/waitlist/waitlist.ts
new file mode 100644
index 00000000..b5648907
--- /dev/null
+++ b/src/servers/api/waitlist/waitlist.ts
@@ -0,0 +1,33 @@
+import { join } from 'node:path';
+import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
+import { createRouter } from '../../create-router';
+import { DATA_PATH } from '../../data-path';
+
+export const waitlistRouter = createRouter();
+
+const WAITLIST_FILE = join(DATA_PATH, 'waitlist.txt');
+
+waitlistRouter.post('/', async (ctx) => {
+ const body = await ctx.req.json<{ email?: string }>();
+ const email = body.email?.trim().toLowerCase();
+
+ if (!email || !email.includes('@')) {
+ return ctx.json({ error: 'Invalid email' }, 400);
+ }
+
+ const dir = DATA_PATH;
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+
+ if (existsSync(WAITLIST_FILE)) {
+ const existing = readFileSync(WAITLIST_FILE, 'utf-8');
+ const emails = existing.split('\n').map((l) => l.trim().toLowerCase());
+ if (emails.includes(email)) {
+ return ctx.json({ ok: true });
+ }
+ }
+
+ appendFileSync(WAITLIST_FILE, email + '\n', 'utf-8');
+ return ctx.json({ ok: true });
+});
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 594618f7..6aece0c5 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -5,6 +5,7 @@ import type { HonoVariables } from './create-router';
import { authRouter } from './api/auth';
import { serverSettingsRouter } from './api/server-settings/server-settings';
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
+import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { plansRouter } from './api/plans/plans';
import { skillsRouter } from './api/skills/skills';
@@ -44,6 +45,7 @@ honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
honoServer.route('/api/auth', authRouter);
honoServer.route('/api/server-settings', serverSettingsRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter);
+honoServer.route('/api/waitlist', waitlistRouter);
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);