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