feat: restore mini previews on the landing hero

The animated product mini-previews are back, recovered from before
the redesign into components/landing/mini-previews.tsx ('use client',
own module for separation). They render framed as product screenshots
(window chrome + bordered surface in the landing palette) in a
two-column hero, so they read as the real app sitting inside the
editorial marketing page. Hidden below 900px.

266/266 tests pass. Build clean. No migration.
This commit is contained in:
Moheeb Zara
2026-05-15 16:41:03 -07:00
parent 43f9c09d70
commit b769414f6d
3 changed files with 306 additions and 15 deletions
+20
View File
@@ -77,6 +77,26 @@
/* Hero */ /* Hero */
.landing-root .landing-hero { padding: 88px 0 72px; border-bottom: 1px solid var(--ln-border); } .landing-root .landing-hero { padding: 88px 0 72px; border-bottom: 1px solid var(--ln-border); }
.landing-root .landing-hero-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: center;
}
.landing-root .landing-preview-frame {
border: 1px solid var(--ln-border);
background: var(--ln-surface);
border-radius: 8px;
padding: 14px;
}
.landing-root .landing-preview-chrome {
display: flex; gap: 6px; padding: 2px 4px 12px;
}
.landing-root .landing-preview-chrome span {
width: 9px; height: 9px; border-radius: 50%;
background: var(--ln-border);
}
@media (max-width: 900px) {
.landing-root .landing-hero-grid { grid-template-columns: 1fr; gap: 40px; }
.landing-root .landing-hero-visual { display: none; }
}
.landing-root .landing-eyebrow { .landing-root .landing-eyebrow {
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.18em; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--ln-text-dim); margin-bottom: 24px; text-transform: uppercase; color: var(--ln-text-dim); margin-bottom: 24px;
+26 -15
View File
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { BrandMark } from '@/components/brand-mark' import { BrandMark } from '@/components/brand-mark'
import { GithubIcon, ArrowIcon } from '@/components/landing/icons' import { GithubIcon, ArrowIcon } from '@/components/landing/icons'
import { ResourceShowcase } from '@/components/landing/resource-showcase' import { ResourceShowcase } from '@/components/landing/resource-showcase'
import { HeroPreviewCluster } from '@/components/landing/mini-previews'
const MODULES = [ const MODULES = [
{ num: '01', title: 'Members', desc: 'Tiers, dues status, contact info, custom roles. Know who is current and who is not, at a glance.' }, { num: '01', title: 'Members', desc: 'Tiers, dues status, contact info, custom roles. Know who is current and who is not, at a glance.' },
@@ -45,21 +46,31 @@ export default function LandingPage() {
</nav> </nav>
<header className="landing-hero"> <header className="landing-hero">
<div className="landing-container"> <div className="landing-container landing-hero-grid">
<p className="landing-eyebrow">Operating system for hackerspaces</p> <div>
<h1 className="landing-display"> <p className="landing-eyebrow">Operating system for hackerspaces</p>
Run your <em>space</em>, not a spreadsheet <h1 className="landing-display">
</h1> Run your <em>space</em>, not a spreadsheet
<p className="landing-lede"> </h1>
Members, dues, tasks, projects, ops docs, and governance in one <p className="landing-lede">
place. Built by people who have actually run these spaces, for the Members, dues, tasks, projects, ops docs, and governance in one
people who keep them running. place. Built by people who have actually run these spaces, for the
</p> people who keep them running.
<div className="landing-cta-row"> </p>
<Link href="/signup" className="landing-btn-accent"> <div className="landing-cta-row">
Get started <ArrowIcon className="w-3.5 h-3.5" /> <Link href="/signup" className="landing-btn-accent">
</Link> Get started <ArrowIcon className="w-3.5 h-3.5" />
<Link href="/login" className="landing-btn">Log in</Link> </Link>
<Link href="/login" className="landing-btn">Log in</Link>
</div>
</div>
<div className="landing-hero-visual">
<div className="landing-preview-frame">
<div className="landing-preview-chrome" aria-hidden="true">
<span /><span /><span />
</div>
<HeroPreviewCluster />
</div>
</div> </div>
</div> </div>
</header> </header>
+260
View File
@@ -0,0 +1,260 @@
'use client'
import { useState, useEffect } from 'react'
// Animated product mini-previews shown framed on the landing hero, as if
// they were small screenshots of the real app. They intentionally use the
// app's own design tokens (bg-card, text-foreground, etc.) so they read as
// the actual product UI sitting inside the editorial marketing page.
function IcoUsers({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg>
}
function IcoTask({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
}
function IcoLock({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
}
function IcoDoc({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
}
function IcoEye({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
function IcoPin({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
}
export function MiniMemberTable() {
const [activeRow, setActiveRow] = useState(0)
const members = [
{ name: 'Alex Chen', tier: 'PLUS', status: 'current' },
{ name: 'Jordan Mills', tier: 'BASIC', status: 'current' },
{ name: 'Sam Rivera', tier: 'PLUS', status: 'late' },
{ name: 'Casey Wong', tier: 'BASIC', status: 'current' },
]
useEffect(() => {
const t = setInterval(() => setActiveRow(p => (p + 1) % members.length), 2000)
return () => clearInterval(t)
}, [members.length])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5 flex items-center justify-between">
<span className="text-white/90 font-medium">Members</span>
<span className="text-white/50 font-mono">{members.length}</span>
</div>
<div className="divide-y divide-border">
{members.map((m, i) => (
<div key={m.name} className={`flex items-center gap-2 px-2 py-1.5 transition-all duration-500 ${i === activeRow ? 'bg-primary/5' : ''}`}>
<div className={`w-5 h-5 rounded flex items-center justify-center font-mono text-[8px] font-bold transition-all duration-500 ${i === activeRow ? 'bg-primary text-white' : 'bg-muted text-muted-foreground'}`}>
{m.name.split(' ').map(n => n[0]).join('')}
</div>
<span className="flex-1 text-foreground truncate">{m.name}</span>
<span className={`font-mono text-[8px] px-1 py-0.5 rounded ${m.tier === 'PLUS' ? 'text-blue-500 bg-blue-500/10' : 'text-muted-foreground bg-muted'}`}>{m.tier}</span>
<span className={`w-1.5 h-1.5 rounded-full ${m.status === 'current' ? 'bg-primary' : 'bg-orange-400'}`} />
</div>
))}
</div>
</div>
)
}
export function MiniTaskList() {
const [checkedIndex, setCheckedIndex] = useState(-1)
const tasks = [
{ title: 'Clean laser cutter lens', area: 'Laser' },
{ title: 'Restock filament', area: '3D Print' },
{ title: 'Update wiki docs', area: 'Admin' },
]
useEffect(() => {
const t = setInterval(() => {
setCheckedIndex(p => (p >= tasks.length - 1 ? -1 : p + 1))
}, 1500)
return () => clearInterval(t)
}, [tasks.length])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5 flex items-center justify-between">
<span className="text-white/90 font-medium">Tasks</span>
<span className="text-primary font-mono">+</span>
</div>
<div className="divide-y divide-border">
{tasks.map((t, i) => (
<div key={t.title} className="flex items-center gap-2 px-2 py-1.5">
<div className={`w-3 h-3 rounded flex items-center justify-center transition-all duration-300 ${i <= checkedIndex ? 'bg-primary' : 'border border-border'}`}>
{i <= checkedIndex && (
<svg className="w-2 h-2 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className={`flex-1 truncate transition-all duration-300 ${i <= checkedIndex ? 'text-muted-foreground line-through' : 'text-foreground'}`}>{t.title}</span>
<span className="text-[8px] text-muted-foreground font-mono">{t.area}</span>
</div>
))}
</div>
</div>
)
}
export function MiniPayments() {
const [linking, setLinking] = useState(false)
useEffect(() => {
const t = setInterval(() => setLinking(p => !p), 2000)
return () => clearInterval(t)
}, [])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5 flex items-center justify-between">
<span className="text-white/90 font-medium">Payments</span>
<span className="text-orange-400 font-mono text-[8px]">2 unlinked</span>
</div>
<div className="divide-y divide-border">
<div className="flex items-center gap-2 px-2 py-1.5">
<span className="text-blue-500 font-mono text-[8px] font-bold">PAYPAL</span>
<span className="flex-1 text-foreground">$50.00</span>
<span className={`font-mono text-[8px] px-1 py-0.5 rounded transition-all duration-500 ${linking ? 'text-primary bg-primary/10 border border-primary/20' : 'text-muted-foreground border border-dashed border-border'}`}>
{linking ? 'LINKED' : '+ LINK'}
</span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5">
<span className="text-green-500 font-mono text-[8px] font-bold">VENMO</span>
<span className="flex-1 text-foreground">$75.00</span>
<span className="font-mono text-[8px] px-1 py-0.5 rounded text-primary bg-primary/10 border border-primary/20">LINKED</span>
</div>
</div>
</div>
)
}
export function MiniOps() {
const [revealedIndex, setRevealedIndex] = useState(-1)
const [tab, setTab] = useState<'secrets' | 'kb'>('secrets')
const secrets = [
{ label: 'WiFi Password', area: 'Network' },
{ label: 'Alarm Code', area: 'Security' },
{ label: 'PayPal API Key', area: 'Finance' },
]
const kb = [
{ title: 'Open / Close Procedure', area: 'Ops', pinned: true },
{ title: 'Laser Cutter Safety', area: 'Laser', pinned: false },
{ title: 'Board Meeting Runbook', area: 'Admin', pinned: true },
]
useEffect(() => {
if (tab !== 'secrets') return
const t = setInterval(() => {
setRevealedIndex(p => (p >= secrets.length - 1 ? -1 : p + 1))
}, 1800)
return () => clearInterval(t)
}, [tab, secrets.length])
useEffect(() => {
const t = setInterval(() => {
setTab(p => (p === 'secrets' ? 'kb' : 'secrets'))
setRevealedIndex(-1)
}, 5000)
return () => clearInterval(t)
}, [])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5">
<span className="text-white/90 font-medium">Ops</span>
</div>
<div className="flex border-b border-border">
{(['secrets', 'kb'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-1 font-mono text-[8px] transition-all duration-300 ${tab === t ? 'text-primary border-b border-primary' : 'text-muted-foreground'}`}
>
{t === 'secrets' ? 'SECRETS' : 'KNOWLEDGE BASE'}
</button>
))}
</div>
<div className="divide-y divide-border">
{tab === 'secrets' && secrets.map((s, i) => (
<div key={s.label} className="flex items-center gap-2 px-2 py-1.5">
<div className="w-4 h-4 rounded bg-amber-500/10 flex items-center justify-center flex-shrink-0">
<IcoLock className="w-2.5 h-2.5 text-amber-500" />
</div>
<span className="flex-1 text-foreground truncate">{s.label}</span>
<span className="text-muted-foreground font-mono text-[8px]">{s.area}</span>
<div className={`flex items-center gap-0.5 font-mono text-[8px] px-1 py-0.5 rounded border transition-all duration-400 ${
i === revealedIndex ? 'text-primary border-primary/30 bg-primary/5' : 'text-muted-foreground border-border'
}`}>
<IcoEye className="w-2 h-2" />
{i === revealedIndex ? <span className="font-mono text-[7px] text-primary">••••1234</span> : <span>reveal</span>}
</div>
</div>
))}
{tab === 'kb' && kb.map((k) => (
<div key={k.title} className={`flex items-center gap-2 px-2 py-1.5 border-l-2 ${k.pinned ? 'border-l-primary' : 'border-l-transparent'}`}>
<div className="w-4 h-4 rounded bg-muted flex items-center justify-center flex-shrink-0">
<IcoDoc className="w-2.5 h-2.5 text-muted-foreground" />
</div>
<span className="flex-1 text-foreground truncate text-[9px]">{k.title}</span>
{k.pinned && <IcoPin className="w-2.5 h-2.5 text-primary flex-shrink-0" />}
<span className="text-muted-foreground font-mono text-[8px]">{k.area}</span>
</div>
))}
</div>
</div>
)
}
export function MiniDashboard() {
const [tick, setTick] = useState(0)
useEffect(() => {
const t = setInterval(() => setTick(p => p + 1), 3000)
return () => clearInterval(t)
}, [])
const members = 47 + (tick % 3)
return (
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-card border border-border rounded p-2">
<div className="flex items-center gap-1 mb-1">
<IcoUsers className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-[8px] font-mono text-muted-foreground uppercase">Members</span>
</div>
<p className="text-base font-bold text-primary transition-all duration-300">{members}</p>
</div>
<div className="bg-card border border-border rounded p-2">
<div className="flex items-center gap-1 mb-1">
<IcoTask className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-[8px] font-mono text-muted-foreground uppercase">Tasks</span>
</div>
<p className="text-base font-bold text-orange-500">12</p>
</div>
<div className="bg-card border border-border rounded p-2 col-span-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[8px] font-mono text-muted-foreground uppercase">Due this week</span>
<span className="text-[8px] font-mono text-orange-400">3 late</span>
</div>
<div className="flex gap-0.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className={`flex-1 h-3 rounded-sm transition-all duration-700 ${
i < 3 ? 'bg-orange-400' : i < 9 ? 'bg-primary' : 'bg-muted'
}`} />
))}
</div>
</div>
</div>
)
}
// Curated cluster used on the landing hero.
export function HeroPreviewCluster() {
return (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<MiniMemberTable />
<MiniDashboard />
</div>
<div className="space-y-3 pt-6">
<MiniTaskList />
<MiniPayments />
<MiniOps />
</div>
</div>
)
}