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:
commit
7e441c7454
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.env*
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 · {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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
@ -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 |
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue