Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Binary file added public/images/events/811.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 12 additions & 20 deletions skills/context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
58 changes: 55 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2376,7 +2384,7 @@
}

.event-description {
@apply max-w-lg text-[1rem];
@apply max-w-none text-[1rem];
}

.event-title {
Expand Down Expand Up @@ -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"
Expand Down
98 changes: 98 additions & 0 deletions src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
32 changes: 25 additions & 7 deletions src/components/sections/events-section.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="event-lockup" aria-label={`Cortex ${region ?? "Global"}`}>
Expand Down Expand Up @@ -101,7 +110,9 @@ function EventCardContent({ event }: { event: CortexEvent }) {
<EventPoster event={event} />

<div className="event-card-copy">
<p className="event-description">{event.description}</p>
<p className="event-description event-description-clamp">
{event.description}
</p>
<EventMeta event={event} />
</div>
</>
Expand All @@ -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] ?? "",
Expand Down Expand Up @@ -160,17 +172,26 @@ export function EventsSection() {
<span key={line} className="events-title-line">
{lineText}
{isLastLine && lastLineAccent ? (
<span className="text-action-primary">{lastLineAccent}</span>
<span className="text-action-primary">
{lastLineAccent}
</span>
) : null}
</span>
);
})}
</h2>
{hasMultipleUpcomingEvents ? (
<EventCarouselControls listId={EVENTS_LIST_ID} />
) : null}
</div>

{hasUpcomingEvents ? (
<div className="site-container events-list-wrap">
<ul className="events-list" aria-label="Upcoming events">
<ul
id={EVENTS_LIST_ID}
className="events-list"
aria-label="Upcoming events"
>
{upcomingEvents.map((event) => (
<li key={event.id} className="events-list-item">
<EventCard event={event} />
Expand All @@ -191,10 +212,7 @@ export function EventsSection() {
<p className="events-follow-up-kicker" data-events-follow-up-kicker>
Next on the network
</p>
<h3
className="events-follow-up-title"
data-events-follow-up-title
>
<h3 className="events-follow-up-title" data-events-follow-up-title>
{eventsSection.followUp.title}
</h3>
<p
Expand Down
Loading
Loading