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 */} +
+
+ Officer Logo +
+
+ Officer 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 ( +
+
+ + + + + + + setIsLoaded(true)} /> + +
+
+ ); +} 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. +

+
+ setEmail(ev.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + placeholder="you@example.com" + className="flex-1 h-10 rounded-md border border-input bg-background px-4 py-2.5 text-base text-duck-dark placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + /> + +
+ + )} +
+ ); +} 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);