From 7e441c745485d68ed1c009007742619d0a398e80 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Feb 2026 17:38:32 -0700 Subject: [PATCH] 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 --- .dockerignore | 8 + .gitignore | 37 + Dockerfile | 32 + app/globals.css | 424 +++++++++++ app/layout.tsx | 66 ++ app/page.tsx | 30 + components.json | 21 + components/cta-section.tsx | 81 ++ components/footer.tsx | 30 + components/glitch-text.tsx | 27 + components/hero.tsx | 86 +++ components/mode-toggle.tsx | 49 ++ components/models-section.tsx | 244 ++++++ components/navigation.tsx | 112 +++ components/pricing-chart.tsx | 193 +++++ components/problem-section.tsx | 69 ++ components/theme-provider.tsx | 11 + components/ui/badge.tsx | 40 + components/ui/button.tsx | 59 ++ components/ui/card.tsx | 78 ++ components/ui/input.tsx | 21 + components/ui/tabs.tsx | 55 ++ components/vision-section.tsx | 127 ++++ components/why-lol-section.tsx | 74 ++ docker-compose.yml | 27 + hooks/use-intersection.ts | 27 + hooks/use-scroll-progress.ts | 40 + lib/pricing-data.ts | 39 + lib/utils.ts | 6 + next.config.mjs | 12 + package.json | 34 + pnpm-lock.yaml | 1288 ++++++++++++++++++++++++++++++++ postcss.config.mjs | 8 + public/icon.svg | 8 + tsconfig.json | 41 + 35 files changed, 3504 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/cta-section.tsx create mode 100644 components/footer.tsx create mode 100644 components/glitch-text.tsx create mode 100644 components/hero.tsx create mode 100644 components/mode-toggle.tsx create mode 100644 components/models-section.tsx create mode 100644 components/navigation.tsx create mode 100644 components/pricing-chart.tsx create mode 100644 components/problem-section.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/vision-section.tsx create mode 100644 components/why-lol-section.tsx create mode 100644 docker-compose.yml create mode 100644 hooks/use-intersection.ts create mode 100644 hooks/use-scroll-progress.ts create mode 100644 lib/pricing-data.ts create mode 100644 lib/utils.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/icon.svg create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc8d9d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +*.md +Dockerfile +docker-compose.yml +.env* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abf07ab --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3788cf0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b49c348 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..5626b64 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + {children} + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..6671b3a --- /dev/null +++ b/app/page.tsx @@ -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 ( + <> + +
+ + + + + + +
+