diff --git a/next.config.ts b/next.config.ts index 4bad628..90b6a7f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,9 @@ import { fileURLToPath } from "node:url"; const projectRoot = path.dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { + experimental: { + optimizePackageImports: ["lucide-react"], + }, turbopack: { root: projectRoot, }, diff --git a/public/images/events/811.png b/public/images/events/811.png new file mode 100644 index 0000000..936ec75 Binary files /dev/null and b/public/images/events/811.png differ diff --git a/skills/context/progress-tracker.md b/skills/context/progress-tracker.md index f771590..7fb33ae 100644 --- a/skills/context/progress-tracker.md +++ b/skills/context/progress-tracker.md @@ -28,7 +28,7 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran - **Team** -- team member cards. Member names link to X profile (tappable on mobile/tablet). - **Monad** -- GSAP hover/dialog animations, `MonadCardsClient` with Radix dialog (desktop) + Sheet (mobile), `TooltipProvider` in layout. - **Services** -- blob-based static gradient cards with conic-gradient hover animation on CTA tile, responsive layout. Shared `section-title` / `section-intro` classes. Old `--gradient-service-*` CSS vars removed. -- **Events** -- static `CortexEvent` content (CONNEX Tech Fest), responsive cards, poster, countdown, RSVP, desktop cursor preview (`xl+`), hourly ISR date filtering. +- **Events** -- static `CortexEvent` content (CONNEX Tech Fest, Cortex Atlanta stablecoins/payments gathering), responsive cards, mobile snap carousel with conditional arrow controls, poster, countdown, RSVP, desktop cursor preview (`xl+`), hourly ISR date filtering. ### Motion @@ -85,25 +85,17 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Next Steps -1. og image and description. -2. launch metadata. -3. Staking page (remove `TEMP(staking-page)` comments when shipping). +1. Staking page (remove `TEMP(staking-page)` comments when shipping). ## Latest Handoff -- **SEO metadata route cleanup** — `src/app/robots.ts` now omits the optional `host` directive and keeps canonical `Allow` + `Sitemap` output for indexable production hosts while preview/staging hosts remain fully disallowed. -- **Organization JSON-LD hardening** — `src/components/seo/organization-jsonld.tsx` now escapes `<` (`\u003c`) during script serialization per Next.js guidance to guard against markup injection vectors. -- **Verified** — `bun run build` passes; static metadata routes still emit (`/robots.txt`, `/sitemap.xml`) and JSON-LD remains present in built HTML. -- **Hash navigation + ScrollTrigger hardening** — `HashScrollSync` waits for safe `ScrollTrigger.refresh(true)` and Mission pin readiness before restoring deep links; abandoned restores cancel so hash spy resumes after route changes. `hash-navigation.ts` kills competing scroll tweens, updates spy on focus-in, keeps `#footer` while visible/focused/at max scroll, and resolves active hash via viewport/header intersection with a Services boundary hold (`#services` won't demote to `#monad-links`). -- **History runtime crash fixed** — Replaced `ScrollTrigger.batch()` + separate summary trigger with one trigger per milestone (final milestone reveals summary). Fixes `Cannot read properties of undefined (reading 'end')` during `ScrollTrigger.create()`. -- **Mission click-scroll guard** — `mission-pin-stack.ts` returns `null` when pin trigger bounds are non-finite; `mission-cards-client.tsx` bails and requests refresh instead of tweening invalid state. -- **Verified** — typecheck, lint (Monad `_section` warning only), test, build, and headless Chrome on `#history`, `#team`, `#services`, `#events`, `#footer`: reload lands correctly; footer hash persists while visible and clears when scrolled away. -- **Newsletter form refactor** — Turnstile extracted to `use-turnstile.ts`; `NewsletterForm` owns submit UX. Submission id ties UI feedback to the active request (no stale success/error); email clears on success; Turnstile tokens reset after each response. -- **Newsletter pipeline order** — honeypot (silent, before rate limit/Turnstile) → **Upstash** per-IP rate limit → **Cloudflare Siteverify** → **Resend** (`contacts.create` + segments; existing contacts re-subscribed via `contacts.update` when `unsubscribed: true`). Rate limiting runs **before Siteverify** to reduce forged-token abuse. -- **Turnstile config** — Client reads `NEXT_PUBLIC_TURNSTILE_SITE_KEY`; server Siteverify gated by `isNewsletterTurnstileEnabled()` (both keys required). Secret **trimmed** before Siteverify. **Hostname validation**: `TURNSTILE_ALLOWED_HOSTNAMES`, `*.vercel.app` wildcards, `VERCEL_URL` merged for previews; action must match `newsletter`. Script loader stores **loading/loaded/error** on shared script element, uses `turnstile.ready()`, handles remount/retry; copies page **CSP nonce** onto `api.js`. -- **Rate-limit IP hardening** — Parses `x-vercel-forwarded-for`, `Forwarded`, IPv4-with-port, bracketed IPv6; **skips loopback** in production-like runtimes. Unknown IP returns `null` identifier; **fail closed** in production (no shared `local` bucket). -- **Footer hash nav** — `hash-navigation.ts` keeps `#footer` while footer is visible and while focus is in the newsletter/contact region (prevents demotion to `#events` during footer interaction). -- **Resend re-subscribe** — Reactivates unsubscribed contacts only; reactivated members get success message, not already-submitted. -- **Event timing source** — Each event carries two Luma UTC instants: `startsAt` and `endsAt`. Upcoming-event visibility (`getUpcomingEvents`) filters by exact `startsAt` timestamp — events are removed once their start instant passes. The client-side `EventCountdown` uses `getEventTimingState` (discriminated union: `countdown` | `live` | `ended`) to show a ticking clock before start, "Happening now" between start and end, and "Event ended" after `endsAt`. CONNEX: `startsAt` `2026-06-27T13:00:00Z` (9 AM EDT), `endsAt` `2026-06-28T01:00:00Z` (9 PM EDT). -- **Tests** — `submit.test.ts`, `turnstile.test.ts`, `rate-limit.test.ts`, `resend.test.ts`, `countdown.test.ts`, `upcoming.test.ts`. -- **Verified** — `bun run test`, `bun run typecheck`, `bun run lint` (existing Monad `_section` warning only), `bun run build`. Localhost `#footer` Turnstile submit confirmed against Resend. +- **Cortex Atlanta event** -- Second event added to `events.ts` (stablecoins/payments, Aug 11 EDT, Luma RSVP). +- **Events mobile carousel** -- Snap-scroll carousel on mobile with prev/next controls, 4-line description clamp, vertical centering. Tablet/desktop unchanged. +- **Events carousel controls** -- Hydration fix (both flags init `true`), `useOnMount` subscription, `ResizeObserver` instead of `window.resize`, functional `setScrollState` bail-out, `next/dynamic` lazy import. +- **SEO** -- `robots.ts` cleanup, JSON-LD `<` escaping, `optimizePackageImports` for `lucide-react`, sitemap/robots routes. +- **Hash nav hardening** -- ScrollTrigger-safe deep links, footer hash persistence, Services boundary hold, History crash fix, Mission bounds guard. +- **Newsletter** -- Turnstile extraction, honeypot > rate-limit > Siteverify > Resend pipeline, IP hardening (fail closed), re-subscribe flow. +- **Event timing** -- `startsAt`/`endsAt` UTC instants, countdown/live/ended states, hourly ISR filtering. +- **llms.txt** -- Dynamic route at `/llms.txt` pulling from content modules (mission, services, team, events, history, socials). Expired events auto-drop via `getUpcomingEvents`. +- **Aria fix** -- Corrected Atlanta event poster alt text to match title. +- **Verified** -- typecheck, test, lint, build pass. diff --git a/src/app/globals.css b/src/app/globals.css index 10b1fa9..30c5c0f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2143,6 +2143,10 @@ @apply block; } + .events-carousel-controls { + @apply hidden; + } + .events-list { @apply m-0 list-none border-y border-border-default p-0; } @@ -2193,7 +2197,7 @@ } .event-description { - @apply max-w-none font-mona text-[0.8125rem] leading-[1.5] text-text-muted md:font-open md:text-[0.875rem] md:leading-[1.3] md:text-text-secondary; + @apply max-w-none font-mona text-[0.8125rem] leading-[1.4] text-text-muted md:font-open md:text-[0.875rem] md:text-text-secondary; } .event-meta { @@ -2315,7 +2319,11 @@ } .event-description { - @apply text-[0.9375rem] leading-[1.45] text-text-secondary; + @apply text-[0.9375rem] leading-[1.5] text-text-secondary; + } + + .event-description-clamp { + @apply line-clamp-4; } .event-meta { @@ -2376,7 +2384,7 @@ } .event-description { - @apply max-w-lg text-[1rem]; + @apply max-w-none text-[1rem]; } .event-title { @@ -2415,6 +2423,50 @@ } @media (width < 48rem) { + .events-section .section-intro { + @apply grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 pb-8; + } + + .events-carousel-controls { + @apply mt-1 flex shrink-0 justify-end gap-2; + } + + .events-carousel-control { + @apply inline-flex size-10 items-center justify-center rounded-full border border-border-default bg-bg-elevated text-text-secondary transition-colors duration-200 hover:border-action-primary hover:text-action-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-action-primary disabled:pointer-events-none disabled:opacity-35; + } + + .events-carousel-control-icon { + @apply size-5; + } + + .events-list { + align-items: flex-start; + display: flex; + gap: 1rem; + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + } + + .events-list::-webkit-scrollbar { + display: none; + } + + .events-list-item { + align-self: flex-start; + display: flex; + flex: 0 0 100%; + scroll-snap-align: start; + scroll-snap-stop: always; + } + + .events-list-item .event-card { + align-self: flex-start; + width: 100%; + } + .event-card-main { grid-template-areas: "primary" diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts new file mode 100644 index 0000000..dded986 --- /dev/null +++ b/src/app/llms.txt/route.ts @@ -0,0 +1,98 @@ +import { + SITE_URL, + SITE_NAME, + SITE_TAGLINE, + SITE_DESCRIPTION, +} from "@/lib/site"; +import { missionSection } from "@/lib/content/mission"; +import { servicesSection } from "@/lib/content/services"; +import { teamSection } from "@/lib/content/team"; +import { eventsSection } from "@/lib/content/events"; +import { historySection } from "@/lib/content/history"; +import { externalLinks } from "@/lib/content/links"; +import { getUpcomingEvents } from "@/lib/events/upcoming"; + +function lines(...parts: string[]): string { + return parts.join("\n"); +} + +function section(title: string, body: string): string { + return `## ${title}\n\n${body}`; +} + +function buildLlmsTxt(): string { + const missionBody = missionSection.paragraphs + .map((p) => (p.emphasis ? `${p.emphasis} ${p.text}` : p.text)) + .join("\n\n"); + + const missionCards = missionSection.cards + .map((c) => `- **${c.title}** ${c.body}`) + .join("\n"); + + const services = servicesSection.cards + .map((s) => `- **${s.title}**: ${s.description}`) + .join("\n"); + + const team = teamSection.members + .map((m) => `- **${m.name}** — ${m.role}. ${m.bio}`) + .join("\n"); + + const upcomingEvents = getUpcomingEvents(eventsSection.events); + const eventsList = + upcomingEvents.length > 0 + ? upcomingEvents + .map( + (e) => + `- **${e.title}** — ${e.dateLabel}, ${e.location.city}. ${e.description}${e.url ? ` [RSVP](${e.url})` : ""}`, + ) + .join("\n") + : "No upcoming events scheduled. Check back soon."; + + const milestones = historySection.milestones + .map((m) => `- **${m.dateLabel}** — ${m.title}`) + .join("\n"); + + const socials = [ + `- [X](${externalLinks.cortexX.href})`, + `- [GitHub](${externalLinks.cortexGitHub.href})`, + `- [YouTube](${externalLinks.cortexYouTube.href})`, + `- [Instagram](${externalLinks.cortexInstagram.href})`, + `- [LinkedIn](${externalLinks.cortexLinkedIn.href})`, + `- [TikTok](${externalLinks.cortexTikTok.href})`, + ].join("\n"); + + return lines( + `# ${SITE_NAME}`, + "", + `> ${SITE_TAGLINE}`, + "", + SITE_DESCRIPTION, + "", + `- Website: ${SITE_URL}`, + `- Privacy policy: ${SITE_URL}/privacy`, + `- Terms of use: ${SITE_URL}/terms`, + "", + section("Mission", `${missionBody}\n\n${missionCards}`), + "", + section("Services", services), + "", + section("Team", team), + "", + section("Upcoming Events", eventsList), + "", + section("History", `${historySection.scrollCallout}\n\n${milestones}`), + "", + section("Connect", socials), + "", + ); +} + +export function GET() { + return new Response(buildLlmsTxt(), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": + "public, max-age=3600, s-maxage=3600, stale-while-revalidate=600", + }, + }); +} diff --git a/src/components/sections/events-section.tsx b/src/components/sections/events-section.tsx index 1a9a34d..93d408a 100644 --- a/src/components/sections/events-section.tsx +++ b/src/components/sections/events-section.tsx @@ -1,6 +1,7 @@ import dynamic from "next/dynamic"; import Image from "next/image"; import { MapPin } from "lucide-react"; + import { EventCardLink } from "@/components/sections/events/event-card-link"; import { EventCardRsvp } from "@/components/sections/events/event-card-rsvp"; import { EventCountdown } from "@/components/sections/events/event-countdown"; @@ -15,6 +16,14 @@ const EventsScrollMotion = dynamic(() => ), ); +const EventCarouselControls = dynamic(() => + import("@/components/sections/events/event-carousel-controls").then( + (mod) => mod.EventCarouselControls, + ), +); + +const EVENTS_LIST_ID = "events-list"; + function EventLockup({ region }: { region?: string }) { return (
@@ -101,7 +110,9 @@ function EventCardContent({ event }: { event: CortexEvent }) {
-

{event.description}

+

+ {event.description} +

@@ -124,6 +135,7 @@ function EventCard({ event }: { event: CortexEvent }) { export function EventsSection() { const upcomingEvents = getUpcomingEvents(eventsSection.events); const hasUpcomingEvents = upcomingEvents.length > 0; + const hasMultipleUpcomingEvents = upcomingEvents.length > 1; const lastLineIndex = eventsSection.titleLines.length - 1; const { text: lastLineText, accent: lastLineAccent } = splitTrailingAccent( eventsSection.titleLines[lastLineIndex] ?? "", @@ -160,17 +172,26 @@ export function EventsSection() { {lineText} {isLastLine && lastLineAccent ? ( - {lastLineAccent} + + {lastLineAccent} + ) : null} ); })} + {hasMultipleUpcomingEvents ? ( + + ) : null}
{hasUpcomingEvents ? (
-