Initial commit: flightclub.lol landing page

Ergodically aligned collective mechanism to protect against extractive
flight pricing algorithms. Next.js 15 + Tailwind CSS 4 with dual aesthetic
system (pastel light mode / cyberpunk dark mode), glitch effects,
hand-drawn SVG pricing chart, and Docker deployment setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-16 17:38:32 -07:00
commit 7e441c7454
35 changed files with 3504 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
.next
.git
.gitignore
*.md
Dockerfile
docker-compose.yml
.env*

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts

32
Dockerfile Normal file
View File

@ -0,0 +1,32 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

424
app/globals.css Normal file
View File

@ -0,0 +1,424 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* ============================================
LIGHT MODE "Innocent / LOL" surface
Bubbly pastels, cotton candy, friendly SaaS
============================================ */
:root {
--background: oklch(0.97 0.01 80);
--foreground: oklch(0.25 0.02 260);
--card: oklch(0.99 0.005 80);
--card-foreground: oklch(0.25 0.02 260);
--popover: oklch(0.99 0.005 80);
--popover-foreground: oklch(0.25 0.02 260);
/* Sky blue / cornflower */
--primary: oklch(0.65 0.18 240);
--primary-foreground: oklch(0.98 0.005 80);
/* Soft lavender */
--secondary: oklch(0.75 0.12 300);
--secondary-foreground: oklch(0.25 0.02 260);
--muted: oklch(0.92 0.02 240);
--muted-foreground: oklch(0.55 0.02 260);
/* Peachy coral */
--accent: oklch(0.75 0.15 25);
--accent-foreground: oklch(0.25 0.02 260);
/* Warm red for danger data */
--destructive: oklch(0.60 0.25 25);
--destructive-foreground: oklch(0.98 0.005 80);
--border: oklch(0.88 0.02 260);
--input: oklch(0.88 0.02 260);
--ring: oklch(0.65 0.18 240);
/* Chart colors */
--chart-1: oklch(0.65 0.18 240);
--chart-2: oklch(0.60 0.25 25);
--chart-3: oklch(0.75 0.12 300);
--chart-4: oklch(0.70 0.15 160);
--chart-5: oklch(0.75 0.15 25);
--radius: 1rem;
/* Glitch colors */
--glitch-1: oklch(0.65 0.18 240);
--glitch-2: oklch(0.75 0.15 25);
--glitch-3: oklch(0.75 0.12 300);
/* Scroll depth (set by JS) */
--scroll-depth: 0;
}
/* ============================================
DARK MODE "Cyberpunk / Fight Club" reveal
Terminal green, neon, CRT scanlines
============================================ */
.dark {
--background: oklch(0.10 0.04 160);
--foreground: oklch(0.90 0.20 145);
--card: oklch(0.14 0.04 160);
--card-foreground: oklch(0.90 0.20 145);
--popover: oklch(0.14 0.04 160);
--popover-foreground: oklch(0.90 0.20 145);
/* Neon cyan */
--primary: oklch(0.80 0.25 190);
--primary-foreground: oklch(0.10 0.04 160);
/* Hot magenta */
--secondary: oklch(0.70 0.30 330);
--secondary-foreground: oklch(0.10 0.04 160);
--muted: oklch(0.25 0.04 160);
--muted-foreground: oklch(0.60 0.10 145);
/* Warning amber */
--accent: oklch(0.80 0.25 85);
--accent-foreground: oklch(0.10 0.04 160);
--destructive: oklch(0.65 0.30 25);
--destructive-foreground: oklch(0.98 0.005 80);
--border: oklch(0.30 0.06 160);
--input: oklch(0.25 0.04 160);
--ring: oklch(0.80 0.25 190);
--chart-1: oklch(0.80 0.25 190);
--chart-2: oklch(0.65 0.30 25);
--chart-3: oklch(0.70 0.30 330);
--chart-4: oklch(0.80 0.25 85);
--chart-5: oklch(0.75 0.20 145);
--glitch-1: oklch(0.80 0.25 190);
--glitch-2: oklch(0.70 0.30 330);
--glitch-3: oklch(0.80 0.25 85);
}
@theme inline {
--font-sans: "Nunito", "Nunito Fallback", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
h1, h2, h3, h4, h5, h6 {
@apply text-balance;
}
p {
@apply text-pretty;
}
}
/* ============================================
GLITCH EFFECTS Pure CSS
============================================ */
/* Glitch skew */
@keyframes glitch-skew {
0%, 100% { transform: skew(0deg); }
10% { transform: skew(-2deg); }
20% { transform: skew(0.5deg); }
30% { transform: skew(-0.3deg); }
40% { transform: skew(0.8deg); }
50% { transform: skew(0deg); }
}
/* Glitch clip + shift for ::before pseudo */
@keyframes glitch-before {
0%, 100% {
clip-path: inset(0 0 0 0);
transform: translate(0);
}
5% {
clip-path: inset(20% 0 60% 0);
transform: translate(-3px, 1px);
}
10% {
clip-path: inset(50% 0 30% 0);
transform: translate(3px, -1px);
}
15% {
clip-path: inset(0 0 0 0);
transform: translate(0);
}
}
/* Glitch clip + shift for ::after pseudo */
@keyframes glitch-after {
0%, 100% {
clip-path: inset(0 0 0 0);
transform: translate(0);
}
5% {
clip-path: inset(70% 0 10% 0);
transform: translate(2px, 2px);
}
10% {
clip-path: inset(10% 0 80% 0);
transform: translate(-2px, -1px);
}
15% {
clip-path: inset(0 0 0 0);
transform: translate(0);
}
}
/* Color channel split */
@keyframes glitch-color {
0%, 100% { text-shadow: none; }
25% { text-shadow: -2px 0 var(--glitch-1), 2px 0 var(--glitch-2); }
50% { text-shadow: 2px 0 var(--glitch-3), -2px 0 var(--glitch-1); }
75% { text-shadow: -1px 0 var(--glitch-2), 1px 0 var(--glitch-3); }
}
/* The glitch text component classes */
.glitch-text {
position: relative;
display: inline-block;
}
.glitch-text::before,
.glitch-text::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0;
}
/* Hover trigger (light mode default) */
.glitch-text:hover::before,
.glitch-text[data-active="true"]::before {
opacity: 0.8;
animation: glitch-before 0.6s steps(2) infinite;
color: var(--glitch-1);
}
.glitch-text:hover::after,
.glitch-text[data-active="true"]::after {
opacity: 0.8;
animation: glitch-after 0.6s steps(2) infinite reverse;
color: var(--glitch-2);
}
.glitch-text:hover,
.glitch-text[data-active="true"] {
animation: glitch-skew 0.5s steps(2) infinite, glitch-color 0.3s steps(2) infinite;
}
/* Dark mode: always active at low intensity */
:is(.dark *) .glitch-text::before {
opacity: 0.3;
animation: glitch-before 4s steps(2) infinite;
color: var(--glitch-1);
}
:is(.dark *) .glitch-text::after {
opacity: 0.3;
animation: glitch-after 4s steps(2) infinite reverse;
color: var(--glitch-2);
}
:is(.dark *) .glitch-text {
animation: glitch-color 6s steps(2) infinite;
}
:is(.dark *) .glitch-text:hover::before {
opacity: 0.8;
animation-duration: 0.4s;
}
:is(.dark *) .glitch-text:hover::after {
opacity: 0.8;
animation-duration: 0.4s;
}
/* ============================================
FLOATING ANIMATIONS
============================================ */
@keyframes float-gentle {
0%, 100% { transform: translateY(0px) rotate(0deg); }
25% { transform: translateY(-15px) rotate(2deg); }
50% { transform: translateY(-5px) rotate(-1deg); }
75% { transform: translateY(-20px) rotate(1deg); }
}
@keyframes float-drift {
0%, 100% { transform: translateY(0px) translateX(0px); }
25% { transform: translateY(-20px) translateX(10px); }
50% { transform: translateY(0px) translateX(20px); }
75% { transform: translateY(20px) translateX(10px); }
}
@keyframes float-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
/* ============================================
SVG DRAW ANIMATION
============================================ */
@keyframes draw-line {
to {
stroke-dashoffset: 0;
}
}
.draw-path {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
}
.draw-path[data-visible="true"] {
animation: draw-line 2s ease-out forwards;
}
/* ============================================
SCANLINE OVERLAY
============================================ */
.scanlines::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10;
background: repeating-linear-gradient(
transparent,
transparent 1px,
oklch(0.50 0.20 145 / 0.03) 1px,
oklch(0.50 0.20 145 / 0.03) 2px
);
opacity: 0;
transition: opacity 0.5s ease;
}
:is(.dark *) .scanlines::after {
opacity: 1;
}
.scanlines[data-in-view="true"]::after {
opacity: 0.5;
}
:is(.dark *) .scanlines[data-in-view="true"]::after {
opacity: 1;
}
/* ============================================
CRT GLOW (dark mode SVG charts)
============================================ */
:is(.dark *) .crt-glow {
filter: drop-shadow(0 0 4px var(--primary)) drop-shadow(0 0 8px var(--primary));
}
/* ============================================
MODE SWITCH FLASH
============================================ */
@keyframes theme-glitch-flash {
0% { opacity: 0; }
10% { opacity: 1; clip-path: inset(30% 0 50% 0); }
20% { opacity: 0; }
30% { opacity: 1; clip-path: inset(60% 0 20% 0); }
40% { opacity: 0; }
50% { opacity: 1; clip-path: inset(10% 0 70% 0); }
60% { opacity: 0; }
100% { opacity: 0; }
}
.theme-flash {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: linear-gradient(
135deg,
oklch(0.80 0.25 190 / 0.4),
oklch(0.70 0.30 330 / 0.4),
oklch(0.80 0.25 85 / 0.4)
);
animation: theme-glitch-flash 0.3s steps(3) forwards;
}
/* ============================================
RESPONSIVE GLITCH REDUCTION
============================================ */
@media (prefers-reduced-motion: reduce) {
.glitch-text::before,
.glitch-text::after,
.glitch-text,
.draw-path[data-visible="true"],
.float-element {
animation: none !important;
}
.draw-path[data-visible="true"] {
stroke-dashoffset: 0;
}
}
/* ============================================
DARK MODE FONT SWITCH
In dark mode, headings use monospace
============================================ */
:is(.dark *) h1,
:is(.dark *) h2,
:is(.dark *) h3 {
font-family: var(--font-mono);
letter-spacing: -0.02em;
}

66
app/layout.tsx Normal file
View File

@ -0,0 +1,66 @@
import type React from "react"
import type { Metadata } from "next"
import { Nunito, JetBrains_Mono } from "next/font/google"
import { ThemeProvider } from "@/components/theme-provider"
import "./globals.css"
const nunito = Nunito({
subsets: ["latin"],
variable: "--font-sans",
})
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
})
export const metadata: Metadata = {
title: "Flight Club - Collective Protection from Flight Price Gouging",
description:
"An ergodically aligned mutual pool protecting members from last-minute flight price volatility. Because paying 10x for the same seat shouldn't be normal.",
keywords:
"flight prices, mutual insurance, collective buying, flight options, price protection, last minute flights, price gouging, mutual aid",
openGraph: {
title: "Flight Club",
description: "Stop getting gouged on last-minute flights. Join the pool.",
url: "https://flightclub.lol",
siteName: "Flight Club",
images: [{ url: "/og-image.png", width: 1200, height: 630 }],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title:
"Flight Club - We're just friends who hate paying $800 for a $200 flight :)",
description: "Collective protection from algorithmic price extraction.",
images: ["/og-image.png"],
},
robots: { index: true, follow: true },
icons: {
icon: [{ url: "/icon.svg", type: "image/svg+xml" }],
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${nunito.variable} ${jetbrainsMono.variable} font-sans antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange={false}
>
{children}
</ThemeProvider>
</body>
</html>
)
}

30
app/page.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client"
import { Navigation } from "@/components/navigation"
import { Hero } from "@/components/hero"
import { ProblemSection } from "@/components/problem-section"
import { VisionSection } from "@/components/vision-section"
import { ModelsSection } from "@/components/models-section"
import { WhyLolSection } from "@/components/why-lol-section"
import { CtaSection } from "@/components/cta-section"
import { Footer } from "@/components/footer"
import { useScrollProgress } from "@/hooks/use-scroll-progress"
export default function Home() {
useScrollProgress()
return (
<>
<Navigation />
<main>
<Hero />
<ProblemSection />
<VisionSection />
<ModelsSection />
<WhyLolSection />
<CtaSection />
</main>
<Footer />
</>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,81 @@
"use client"
import { useState } from "react"
import { Send, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export function CtaSection() {
const [email, setEmail] = useState("")
const [submitted, setSubmitted] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email || !email.includes("@")) return
setLoading(true)
// Stub: just simulate a brief delay
await new Promise((r) => setTimeout(r, 600))
setSubmitted(true)
setLoading(false)
}
return (
<section id="cta" className="py-24 px-6">
<div className="max-w-xl mx-auto text-center">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
{`We're building this. Want in?`}
</h2>
<p className="text-lg text-muted-foreground mb-10">
Flight Club is in the research and design phase. Drop your email and
{` we'll`} let you know when {`we're`} ready to fly.
</p>
{submitted ? (
<div className="rounded-xl border bg-primary/5 p-8 space-y-3">
<Check className="size-10 text-primary mx-auto" />
<p className="text-xl font-semibold">
{`You're in the club.`}
</p>
<p className="text-muted-foreground">
First rule: tell everyone.
</p>
</div>
) : (
<form
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto"
>
<Input
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-12 text-base rounded-full px-5"
/>
<Button
type="submit"
size="xl"
disabled={loading}
className="rounded-full shrink-0"
>
{loading ? (
"..."
) : (
<>
Join <Send className="size-4" />
</>
)}
</Button>
</form>
)}
<p className="text-xs text-muted-foreground mt-6">
No spam. No algorithms. Just humans.
</p>
</div>
</section>
)
}

30
components/footer.tsx Normal file
View File

@ -0,0 +1,30 @@
import { Plane } from "lucide-react"
export function Footer() {
return (
<footer className="border-t py-12 px-6">
<div className="max-w-4xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
{/* Logo / Name */}
<div className="flex items-center gap-2">
<Plane className="size-5 text-primary" />
<span className="font-bold text-lg">flightclub.lol</span>
</div>
{/* Tagline */}
<p className="text-sm text-muted-foreground italic text-center">
{`"The house always wins — unless the house is a mutual aid society."`}
</p>
</div>
<div className="mt-8 pt-6 border-t border-border/50 flex flex-col md:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<p>
Not financial advice. Not insurance (yet). Just friends looking out
for friends.
</p>
<p>CC BY-SA 4.0</p>
</div>
</div>
</footer>
)
}

View File

@ -0,0 +1,27 @@
"use client"
import { cn } from "@/lib/utils"
interface GlitchTextProps {
children: string
as?: "h1" | "h2" | "h3" | "span" | "p"
className?: string
active?: boolean
}
export function GlitchText({
children,
as: Tag = "span",
className,
active = false,
}: GlitchTextProps) {
return (
<Tag
className={cn("glitch-text", className)}
data-text={children}
data-active={active}
>
{children}
</Tag>
)
}

86
components/hero.tsx Normal file
View File

@ -0,0 +1,86 @@
"use client"
import { Plane, Cloud, DollarSign, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { GlitchText } from "@/components/glitch-text"
const floatingItems = [
{ Icon: Plane, className: "top-[15%] left-[10%]", delay: "0s", duration: "6s" },
{ Icon: Cloud, className: "top-[25%] right-[15%]", delay: "1s", duration: "8s" },
{ Icon: DollarSign, className: "top-[60%] left-[20%]", delay: "2s", duration: "7s" },
{ Icon: Plane, className: "top-[70%] right-[10%]", delay: "0.5s", duration: "9s" },
{ Icon: Cloud, className: "top-[40%] left-[75%]", delay: "3s", duration: "7.5s" },
{ Icon: DollarSign, className: "top-[20%] left-[50%]", delay: "1.5s", duration: "6.5s" },
]
export function Hero() {
const scrollTo = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" })
}
return (
<section
id="hero"
className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-accent/10 via-background to-primary/10"
>
{/* Floating background elements */}
{floatingItems.map((item, i) => (
<div
key={i}
className={`absolute ${item.className} opacity-[0.12] dark:opacity-[0.08] pointer-events-none`}
style={{
animation: `float-gentle ${item.duration} ease-in-out infinite`,
animationDelay: item.delay,
}}
>
<item.Icon className="size-12 md:size-16 text-primary" />
</div>
))}
{/* Content */}
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center">
<div className="mb-6">
<span className="inline-block px-4 py-1.5 rounded-full bg-primary/10 text-primary text-sm font-medium dark:bg-primary/20">
Coming Soon
</span>
</div>
<GlitchText
as="h1"
className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-6 tracking-tight"
>
{`We're just a bunch of friends who hate paying $800 for a flight that was $200 yesterday :)`}
</GlitchText>
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10">
So we built a way to stop getting gouged. An ergodically aligned
collective mechanism to protect each other from extractive pricing
algorithms.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="xl"
onClick={() => scrollTo("vision")}
className="rounded-full"
>
See How It Works
</Button>
<Button
size="xl"
variant="outline"
onClick={() => scrollTo("cta")}
className="rounded-full"
>
Join the Waitlist
</Button>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<ChevronDown className="size-6 text-muted-foreground" />
</div>
</section>
)
}

View File

@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { useState, useEffect } from "react"
import { Smile, Skull } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [flashing, setFlashing] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) {
return (
<Button variant="ghost" size="icon" className="opacity-0">
<Smile className="size-5" />
</Button>
)
}
const toggleTheme = () => {
setFlashing(true)
setTimeout(() => {
setTheme(theme === "dark" ? "light" : "dark")
setTimeout(() => setFlashing(false), 300)
}, 50)
}
return (
<>
{flashing && <div className="theme-flash" />}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="relative"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Skull className="size-5 text-primary" />
) : (
<Smile className="size-5 text-primary" />
)}
</Button>
</>
)
}

View File

@ -0,0 +1,244 @@
"use client"
import {
Shield,
Lock,
Globe,
Users,
Landmark,
HandHeart,
} from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
export function ModelsSection() {
return (
<section id="models" className="py-24 px-6">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
{`We're exploring several paths.`}
</h2>
<p className="text-lg text-muted-foreground">
All of them beat getting fleeced.
</p>
</div>
<Tabs defaultValue="pool" className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-8">
<TabsTrigger value="pool">The Mutual Pool</TabsTrigger>
<TabsTrigger value="options">Flight Options</TabsTrigger>
<TabsTrigger value="bigger">The Bigger Picture</TabsTrigger>
</TabsList>
{/* Tab 1: Mutual Pool */}
<TabsContent value="pool">
<Card>
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<Shield className="size-6 text-primary" />
<Badge variant="success">Low Risk</Badge>
</div>
<CardTitle className="text-2xl">The Mutual Pool</CardTitle>
<CardDescription className="text-base">
Classic mutual aid: everyone contributes, everyone is covered.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid sm:grid-cols-3 gap-4">
<div className="rounded-lg bg-muted/50 p-4">
<div className="text-2xl font-bold text-primary">$20</div>
<div className="text-sm text-muted-foreground">
/month base dues
</div>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<div className="text-2xl font-bold text-primary">
Shared
</div>
<div className="text-sm text-muted-foreground">
risk pool
</div>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<div className="text-2xl font-bold text-primary">
$100-300
</div>
<div className="text-sm text-muted-foreground">
/year expected savings
</div>
</div>
</div>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-2">
<Users className="size-4 mt-0.5 text-primary shrink-0" />
<span>
Members contribute monthly. When someone faces a
last-minute price spike, the pool covers the difference.
</span>
</div>
<div className="flex items-start gap-2">
<HandHeart className="size-4 mt-0.5 text-primary shrink-0" />
<span>
Phase 1 starts with aggregated buying power: shared travel
agent access, group airline passes, and price-lock tools.
</span>
</div>
</div>
<p className="text-xs text-muted-foreground italic border-t pt-4">
Honest note: Pure insurance has challenges (adverse selection,
moral hazard). {`That's`} why we pair the pool with the models
below.
</p>
</CardContent>
</Card>
</TabsContent>
{/* Tab 2: Flight Options */}
<TabsContent value="options">
<Card>
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<Lock className="size-6 text-primary" />
<Badge variant="warning">Medium Risk</Badge>
</div>
<CardTitle className="text-2xl">Flight Options</CardTitle>
<CardDescription className="text-base">
Financial derivatives for regular people. Lock in today{`'`}s
price, exercise later if you need to.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid sm:grid-cols-3 gap-4">
{[
{
name: "Flex",
price: "$15",
options: "2 options/year",
},
{
name: "Standard",
price: "$35",
options: "4 options/year",
},
{
name: "Premium",
price: "$65",
options: "6 options/year",
},
].map((tier) => (
<div
key={tier.name}
className="rounded-lg border p-4 text-center"
>
<div className="font-semibold text-sm mb-1">
{tier.name}
</div>
<div className="text-2xl font-bold text-primary">
{tier.price}
</div>
<div className="text-xs text-muted-foreground">
/month &middot; {tier.options}
</div>
</div>
))}
</div>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-2">
<Lock className="size-4 mt-0.5 text-primary shrink-0" />
<span>
{`You "lock in" the 21-day-out price for a route. If you need
to fly last-minute, exercise your option and pay the locked
price. The pool absorbs the difference.`}
</span>
</div>
<div className="flex items-start gap-2">
<Landmark className="size-4 mt-0.5 text-primary shrink-0" />
<span>
Expected savings: $200-800/year depending on tier and
usage. Break-even at just 1-2 last-minute flights per
year.
</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Tab 3: Bigger Picture */}
<TabsContent value="bigger">
<Card>
<CardHeader>
<div className="flex items-center gap-3 mb-2">
<Globe className="size-6 text-primary" />
<Badge variant="outline">Exploratory</Badge>
</div>
<CardTitle className="text-2xl">The Bigger Picture</CardTitle>
<CardDescription className="text-base">
What happens when a community negotiates with airlines the way
a corporation does?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4 text-sm">
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="font-semibold mb-1">
Aggregated Buying Power
</h4>
<p className="text-muted-foreground">
Travel agent credentials, consolidator fares, group
airline passes. The deals that corporations and agencies
get, but for regular people.
</p>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="font-semibold mb-1">Credit Union Model</h4>
<p className="text-muted-foreground">
Closed-group social accountability. When everyone knows
each other, gaming the system has social consequences.
This is how mutual aid actually works.
</p>
</div>
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="font-semibold mb-1">
Verified Emergency Coverage
</h4>
<p className="text-muted-foreground">
The hardest version to game: coverage only for documented
emergencies (medical, bereavement, jury duty, natural
disasters). Low premiums, real protection.
</p>
</div>
<div className="rounded-lg border-2 border-dashed border-primary/30 p-4">
<h4 className="font-semibold mb-1 text-primary">
Beyond flights...
</h4>
<p className="text-muted-foreground">
The same ergodic principle applies to any domain where
algorithms extract rents from individual volatility.
Hotels. Concert tickets. Surge pricing. The pattern is the
same. The solution scales.
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</section>
)
}

112
components/navigation.tsx Normal file
View File

@ -0,0 +1,112 @@
"use client"
import { useState, useEffect } from "react"
import { Plane, Menu, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ModeToggle } from "@/components/mode-toggle"
import { cn } from "@/lib/utils"
const navLinks = [
{ label: "Problem", href: "#problem" },
{ label: "Vision", href: "#vision" },
{ label: "Models", href: "#models" },
{ label: "Why lol", href: "#why-lol" },
]
export function Navigation() {
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 50)
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [])
const scrollTo = (href: string) => {
setMobileOpen(false)
const el = document.querySelector(href)
el?.scrollIntoView({ behavior: "smooth" })
}
return (
<nav
className={cn(
"fixed top-0 left-0 right-0 z-50 transition-all duration-300",
scrolled
? "bg-background/80 backdrop-blur-md border-b shadow-sm"
: "bg-transparent"
)}
>
<div className="max-w-5xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Logo */}
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className="flex items-center gap-2 font-bold text-lg hover:text-primary transition-colors"
>
<Plane className="size-5" />
<span>
flightclub
<span className="text-primary">.lol</span>
</span>
</button>
{/* Desktop nav */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Button
key={link.href}
variant="ghost"
size="sm"
onClick={() => scrollTo(link.href)}
>
{link.label}
</Button>
))}
<Button
size="sm"
onClick={() => scrollTo("#cta")}
className="ml-2 rounded-full"
>
Join
</Button>
<ModeToggle />
</div>
{/* Mobile toggle */}
<div className="flex md:hidden items-center gap-2">
<ModeToggle />
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</Button>
</div>
</div>
{/* Mobile menu */}
{mobileOpen && (
<div className="md:hidden bg-background/95 backdrop-blur-md border-b px-6 py-4 space-y-2">
{navLinks.map((link) => (
<button
key={link.href}
onClick={() => scrollTo(link.href)}
className="block w-full text-left py-2 text-sm font-medium hover:text-primary transition-colors"
>
{link.label}
</button>
))}
<Button
size="sm"
onClick={() => scrollTo("#cta")}
className="w-full rounded-full mt-2"
>
Join the Waitlist
</Button>
</div>
)}
</nav>
)
}

View File

@ -0,0 +1,193 @@
"use client"
import { useIntersection } from "@/hooks/use-intersection"
import { domesticPricing, internationalPricing } from "@/lib/pricing-data"
// Convert data points to SVG path coordinates
// Chart: X = 0 (60 days) to 400 (0 days), Y = 0 (top, 10x) to 300 (bottom, 0x)
function toPath(
data: { days: number; multiplier: number }[]
): string {
const points = data.map((d) => ({
x: ((60 - d.days) / 60) * 380 + 40,
y: 280 - (d.multiplier / 10) * 260,
}))
// Smooth bezier curve through points
let path = `M ${points[0].x} ${points[0].y}`
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1]
const curr = points[i]
const cpx = (prev.x + curr.x) / 2
path += ` C ${cpx} ${prev.y}, ${cpx} ${curr.y}, ${curr.x} ${curr.y}`
}
return path
}
export function PricingChart() {
const { ref, isVisible } = useIntersection(0.3)
const domesticPath = toPath(domesticPricing)
const internationalPath = toPath(internationalPricing)
// Y position for 2x multiplier (danger threshold)
const dangerY = 280 - (2 / 10) * 260
return (
<div ref={ref as React.RefObject<HTMLDivElement>} className="w-full max-w-3xl mx-auto">
<svg
viewBox="0 0 440 320"
className="w-full h-auto crt-glow"
preserveAspectRatio="xMidYMid meet"
>
{/* Danger zone (above 2x) */}
<rect
x="40"
y="20"
width="380"
height={dangerY - 20}
fill="var(--destructive)"
opacity="0.06"
rx="4"
/>
{/* Grid lines */}
{[0, 2, 4, 6, 8, 10].map((mult) => {
const y = 280 - (mult / 10) * 260
return (
<g key={mult}>
<line
x1="40"
y1={y}
x2="420"
y2={y}
stroke="var(--border)"
strokeWidth="0.5"
strokeDasharray={mult === 2 ? "none" : "4,4"}
opacity={mult === 2 ? 0.6 : 0.3}
/>
<text
x="32"
y={y + 4}
textAnchor="end"
fill="var(--muted-foreground)"
fontSize="10"
fontFamily="var(--font-mono)"
>
{mult}x
</text>
</g>
)
})}
{/* X-axis labels */}
{[60, 45, 30, 21, 14, 7, 3, 0].map((days) => {
const x = ((60 - days) / 60) * 380 + 40
return (
<text
key={days}
x={x}
y="300"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="9"
fontFamily="var(--font-mono)"
>
{days}d
</text>
)
})}
{/* 2x danger label */}
<text
x="425"
y={dangerY + 4}
fill="var(--destructive)"
fontSize="9"
fontFamily="var(--font-mono)"
fontWeight="bold"
>
2x
</text>
{/* International line */}
<path
d={internationalPath}
fill="none"
stroke="var(--chart-3)"
strokeWidth="2.5"
strokeLinecap="round"
className="draw-path"
data-visible={isVisible}
style={{ animationDelay: "0.3s" }}
/>
{/* Domestic line */}
<path
d={domesticPath}
fill="none"
stroke="var(--chart-1)"
strokeWidth="2.5"
strokeLinecap="round"
className="draw-path"
data-visible={isVisible}
/>
{/* End point dots */}
{isVisible && (
<>
<circle
cx={((60 - 0) / 60) * 380 + 40}
cy={280 - (10 / 10) * 260}
r="4"
fill="var(--chart-1)"
className="animate-in fade-in duration-1000"
style={{ animationDelay: "2s" }}
/>
<circle
cx={((60 - 0) / 60) * 380 + 40}
cy={280 - (5 / 10) * 260}
r="4"
fill="var(--chart-3)"
className="animate-in fade-in duration-1000"
style={{ animationDelay: "2.3s" }}
/>
</>
)}
{/* Axis labels */}
<text
x="230"
y="316"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="10"
>
Days before departure
</text>
<text
x="14"
y="155"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="10"
transform="rotate(-90, 14, 155)"
>
Price multiplier
</text>
</svg>
{/* Legend */}
<div className="flex justify-center gap-6 mt-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[var(--chart-1)] rounded" />
<span className="text-muted-foreground">Domestic</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-[var(--chart-3)] rounded" />
<span className="text-muted-foreground">International</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,69 @@
"use client"
import { AlertTriangle, TrendingUp, Bot } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { PricingChart } from "@/components/pricing-chart"
import { pricingStats } from "@/lib/pricing-data"
const callouts = [
{
icon: AlertTriangle,
title: "Your emergency is their payday",
description: `Average ${pricingStats.avgPremium} premium on last-minute domestic flights. Your urgency is a revenue opportunity.`,
color: "text-destructive",
},
{
icon: TrendingUp,
title: `${pricingStats.markupRange} markup`,
description:
"Same seat. Same plane. Same crew. Different price because you needed it sooner.",
color: "text-accent dark:text-accent",
},
{
icon: Bot,
title: "It's not supply and demand. It's an algorithm.",
description:
"Airlines use dynamic pricing AI trained on one objective: maximize revenue extraction.",
color: "text-primary",
},
]
export function ProblemSection() {
return (
<section id="problem" className="py-24 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Airlines use algorithms to maximize what you pay.
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{`Here's what that looks like.`}
</p>
</div>
{/* Pricing chart */}
<div className="mb-16">
<PricingChart />
</div>
{/* Callout cards */}
<div className="grid md:grid-cols-3 gap-6">
{callouts.map((item) => (
<Card
key={item.title}
className="border-border/50 hover:border-border transition-colors"
>
<CardContent className="pt-6">
<item.icon className={`size-8 mb-4 ${item.color}`} />
<h3 className="font-semibold text-lg mb-2">{item.title}</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
{item.description}
</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

40
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,40 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground',
secondary:
'border-transparent bg-secondary text-secondary-foreground',
destructive:
'border-transparent bg-destructive text-white',
outline: 'text-foreground',
success:
'border-transparent bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
warning:
'border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-400',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof badgeVariants>) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

59
components/ui/button.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
xl: 'h-12 rounded-lg px-8 text-base has-[>svg]:px-5',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

78
components/ui/card.tsx Normal file
View File

@ -0,0 +1,78 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
}

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }

55
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,127 @@
"use client"
import { useIntersection } from "@/hooks/use-intersection"
export function VisionSection() {
const { ref, isVisible } = useIntersection(0.3)
return (
<section
id="vision"
className="py-24 px-6 bg-gradient-to-b from-primary/5 to-secondary/5"
>
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
What if we just... looked out for each other?
</h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Flight Club is an ergodically aligned collective mechanism.
<span className="block mt-1 text-sm italic">
{`(Don't worry, we'll explain.)`}
</span>
</p>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Explanation */}
<div className="space-y-6">
<p className="text-lg leading-relaxed">
In a system with pricing volatility, individuals get destroyed by
outlier events. A $200 flight becomes $1,200 when you need it
most.
</p>
<p className="text-lg leading-relaxed">
But a pool of people, sharing the risk, experiences the{" "}
<strong>average</strong>. The wild swings that bankrupt
individuals become gentle ripples across a collective.
</p>
<blockquote className="border-l-4 border-primary pl-4 py-2 text-lg italic text-muted-foreground">
{`"The house always wins — unless the house is a mutual aid
society."`}
</blockquote>
</div>
{/* Pooling animation SVG */}
<div
ref={ref as React.RefObject<HTMLDivElement>}
className="flex justify-center"
>
<svg viewBox="0 0 300 300" className="w-full max-w-xs crt-glow">
{/* Central pool circle */}
<circle
cx="150"
cy="150"
r="80"
fill="none"
stroke="var(--primary)"
strokeWidth="1.5"
opacity="0.3"
strokeDasharray="4,4"
/>
<circle
cx="150"
cy="150"
r="40"
fill="var(--primary)"
opacity="0.08"
/>
{/* Individual dots - animate from volatile to pooled */}
{Array.from({ length: 12 }).map((_, i) => {
const angle = (i / 12) * Math.PI * 2
const poolX = 150 + Math.cos(angle) * 55
const poolY = 150 + Math.sin(angle) * 55
const chaosX = 150 + Math.cos(angle) * (100 + (i % 3) * 30)
const chaosY = 150 + Math.sin(angle) * (100 + (i % 3) * 30)
return (
<circle
key={i}
r="6"
fill="var(--primary)"
opacity="0.7"
style={{
transition: "all 1.5s cubic-bezier(0.34, 1.56, 0.64, 1)",
transitionDelay: `${i * 0.08}s`,
cx: isVisible ? poolX : chaosX,
cy: isVisible ? poolY : chaosY,
}}
/>
)
})}
{/* Center label */}
<text
x="150"
y="154"
textAnchor="middle"
fill="var(--primary)"
fontSize="12"
fontWeight="bold"
fontFamily="var(--font-mono)"
opacity={isVisible ? 1 : 0}
style={{ transition: "opacity 0.5s ease 1.5s" }}
>
POOL
</text>
{/* Labels */}
<text
x="150"
y="268"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="10"
>
{isVisible
? "Collective: smooth, predictable"
: "Individual: volatile, exposed"}
</text>
</svg>
</div>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,74 @@
"use client"
import { useIntersection } from "@/hooks/use-intersection"
import { GlitchText } from "@/components/glitch-text"
export function WhyLolSection() {
const { ref, isVisible } = useIntersection(0.2)
return (
<section
id="why-lol"
ref={ref as React.RefObject<HTMLElement>}
className="relative py-24 px-6 scanlines overflow-hidden"
data-in-view={isVisible}
>
{/* Gradient background shift */}
<div
className="absolute inset-0 transition-opacity duration-1000 -z-10"
style={{
background:
"linear-gradient(180deg, var(--background), oklch(0.15 0.03 160 / 0.3))",
opacity: isVisible ? 1 : 0,
}}
/>
<div className="max-w-3xl mx-auto relative z-20">
<div className="text-center mb-12">
<GlitchText
as="h2"
className="text-3xl md:text-4xl font-bold mb-2"
active={isVisible}
>
The first rule of Flight Club is: tell EVERYONE about Flight Club.
</GlitchText>
</div>
<div className="space-y-6 text-lg leading-relaxed">
<p>
We put {`"lol"`} in the domain because laughing is what you do when
the alternative is crying into your $1,100 same-day ticket to see
your mom in the hospital.
</p>
<p>
The airline industry made{" "}
<strong className="text-destructive">$29 billion</strong> in profit
last year. They employ teams of PhDs to build pricing algorithms
that extract maximum revenue from your urgency, your grief, your
inflexible schedule.
</p>
<p>
We think the correct response to that is a collective structure that
makes their extraction irrelevant. And also: <em>lol.</em>
</p>
<div className="border-l-4 border-primary/50 pl-6 py-2 my-8">
<p className="text-muted-foreground italic">
{`This isn't a startup. There's no VC money. There's no exit
strategy. It's just people pooling resources to protect each other
from systems designed to pick them clean.`}
</p>
</div>
<p className="text-muted-foreground">
The airlines built an asymmetric weapon: they know your price
sensitivity better than you do, and they use it against you in real
time. We{`'`}re building the collective shield.
</p>
</div>
</div>
</section>
)
}

27
docker-compose.yml Normal file
View File

@ -0,0 +1,27 @@
services:
flight-club:
build: .
container_name: flight-club-lol
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
environment:
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.flight-club.rule=Host(`flightclub.lol`) || Host(`www.flightclub.lol`)"
- "traefik.http.routers.flight-club.entrypoints=web"
- "traefik.http.services.flight-club.loadbalancer.server.port=3000"
networks:
- traefik-public
networks:
traefik-public:
external: true

27
hooks/use-intersection.ts Normal file
View File

@ -0,0 +1,27 @@
"use client"
import { useEffect, useRef, useState } from "react"
export function useIntersection(threshold = 0.2) {
const ref = useRef<HTMLElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
}
},
{ threshold }
)
observer.observe(el)
return () => observer.disconnect()
}, [threshold])
return { ref, isVisible }
}

View File

@ -0,0 +1,40 @@
"use client"
import { useEffect, useRef } from "react"
export function useScrollProgress() {
const rafRef = useRef<number>(0)
const lastValueRef = useRef<number>(0)
useEffect(() => {
const handleScroll = () => {
if (rafRef.current) return
rafRef.current = requestAnimationFrame(() => {
const scrollTop = window.scrollY
const docHeight =
document.documentElement.scrollHeight - window.innerHeight
const progress = docHeight > 0 ? Math.min(scrollTop / docHeight, 1) : 0
// Only update if change is meaningful (> 1%)
if (Math.abs(progress - lastValueRef.current) > 0.01) {
lastValueRef.current = progress
document.documentElement.style.setProperty(
"--scroll-depth",
progress.toFixed(3)
)
}
rafRef.current = 0
})
}
window.addEventListener("scroll", handleScroll, { passive: true })
handleScroll() // Set initial value
return () => {
window.removeEventListener("scroll", handleScroll)
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [])
}

39
lib/pricing-data.ts Normal file
View File

@ -0,0 +1,39 @@
// Pricing curve data from flight-club research
// X = days before departure, Y = price multiplier
export const domesticPricing = [
{ days: 60, multiplier: 1.0 },
{ days: 50, multiplier: 1.05 },
{ days: 40, multiplier: 1.1 },
{ days: 30, multiplier: 1.0 },
{ days: 21, multiplier: 1.0 },
{ days: 14, multiplier: 1.35 },
{ days: 10, multiplier: 1.8 },
{ days: 7, multiplier: 2.5 },
{ days: 5, multiplier: 3.2 },
{ days: 3, multiplier: 5.0 },
{ days: 1, multiplier: 8.0 },
{ days: 0, multiplier: 10.0 },
]
export const internationalPricing = [
{ days: 60, multiplier: 0.95 },
{ days: 50, multiplier: 1.0 },
{ days: 40, multiplier: 1.0 },
{ days: 30, multiplier: 1.0 },
{ days: 21, multiplier: 1.05 },
{ days: 14, multiplier: 1.2 },
{ days: 10, multiplier: 1.5 },
{ days: 7, multiplier: 2.0 },
{ days: 5, multiplier: 2.5 },
{ days: 3, multiplier: 3.5 },
{ days: 1, multiplier: 4.5 },
{ days: 0, multiplier: 5.0 },
]
// Stats for callout cards
export const pricingStats = {
avgPremium: "$700",
markupRange: "3-10x",
airlineProfits: "$29 billion",
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

12
next.config.mjs Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
output: 'standalone',
}
export default nextConfig

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "flight-club-lol",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"start": "next start"
},
"dependencies": {
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-tabs": "1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.454.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}

1288
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

8
public/icon.svg Normal file
View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="15" fill="#7CB3F0" opacity="0.15"/>
<circle cx="16" cy="16" r="15" fill="none" stroke="#7CB3F0" stroke-width="1.5"/>
<!-- Airplane -->
<path d="M8 18 L14 16 L12 12 L15 14 L24 10 L15 18 L17 22 L14 19 L8 18Z" fill="#7CB3F0"/>
<!-- Shield outline -->
<path d="M16 5 L24 9 L24 17 C24 22 20 26 16 28 C12 26 8 22 8 17 L8 9 Z" fill="none" stroke="#7CB3F0" stroke-width="1.2" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 491 B

41
tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}