Skip to content
21 changes: 21 additions & 0 deletions app/api/org-info/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { fetchOrganization, fetchOrganizationMembers } from "@/lib/github/client";

// Fast endpoint: returns org info + member logins (no scouting).
// The client then calls /api/org-scout?login=X for each member progressively.
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const login = searchParams.get("login");
if (!login) return NextResponse.json({ error: "Missing login" }, { status: 400 });

try {
const [org, members] = await Promise.all([
fetchOrganization(login),
fetchOrganizationMembers(login),
]);
return NextResponse.json({ org, members });
} catch (e) {
const err = e as { type?: string; message?: string };
return NextResponse.json({ error: err.message ?? "Failed to fetch org" }, { status: 404 });
}
}
18 changes: 18 additions & 0 deletions app/api/org-scout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { scoutCard } from "@/lib/scout";

// Scout a single member — called progressively by the client to show live
// progress. Returns the Card JSON for one GitHub user.
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const login = searchParams.get("login");
if (!login) return NextResponse.json({ error: "Missing login" }, { status: 400 });

try {
const card = await scoutCard(login);
return NextResponse.json({ card });
} catch (e) {
const err = e as { type?: string; message?: string };
return NextResponse.json({ error: err.message ?? "Scout failed" }, { status: 404 });
}
}
75 changes: 75 additions & 0 deletions app/org/[login]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import Link from "next/link";
import Background from "@/components/Background";
import { fetchOrganization, fetchOrganizationMembers } from "@/lib/github/client";
import { getRepoStars } from "@/lib/github/stars";
import SquadView from "@/components/SquadView";

export const dynamic = "force-dynamic";

export async function generateMetadata({ params }: { params: Promise<{ login: string }> }): Promise<Metadata> {
const { login } = await params;
try {
const org = await fetchOrganization(login);
const clubName = org.name || org.login;
return {
title: `${clubName} FC · GitFut`,
description: `${clubName} scouted on GitFut: build the best XI from GitHub organization members.`,
alternates: { canonical: `/org/${org.login}` },
twitter: { card: "summary_large_image" },
};
} catch {
return { title: `${login} FC · GitFut`, robots: { index: false } };
}
}

function OrgError({ login, message }: { login: string; message: string }) {
return (
<main className="relative z-[2] mx-auto flex min-h-screen max-w-[560px] flex-col items-center justify-center px-6 text-center">
<div className="font-display text-[12px] font-bold tracking-[.3em] text-brand">CLUB SCOUT</div>
<h1 className="font-display mt-3 text-[clamp(30px,6vw,48px)] font-black leading-[.95]">
Club not found
</h1>
<p className="mt-3 text-[15.5px] leading-[1.5] text-ink-soft">{message}</p>
<Link
href="/"
className="font-display mt-7 inline-flex h-[46px] items-center rounded-xl bg-brand px-6 text-[16px] tracking-[.06em] text-[#04130a] transition hover:bg-brand-hi"
>
BACK TO THE BENCH
</Link>
</main>
);
}

export default async function OrgPage({
params,
}: {
params: Promise<{ login: string }>;
}) {
const { login } = await params;
const stars = await getRepoStars();

let orgInfo;
let members;
try {
[orgInfo, members] = await Promise.all([
fetchOrganization(login),
fetchOrganizationMembers(login),
]);
} catch (e) {
const msg = (e as { message?: string }).message ?? "Something went wrong.";
return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
<OrgError login={login} message={msg} />
</div>
);
}

return (
<div className="relative min-h-screen overflow-x-hidden text-ink">
<Background />
<SquadView orgInfo={orgInfo} members={members} stars={stars} />
</div>
);
}
2 changes: 1 addition & 1 deletion app/u/[username]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default async function Image({ params }: { params: Promise<{ username: st
}

const card = { ...raw, country: pickFlag(null, raw.country) ?? "" }; // GitHub-derived flag only
const accent = card.founder?.accent ?? TIER_ACCENT[card.finish] ?? "#39d353";
const accent = card.founder?.accent ?? card.patron?.accent ?? TIER_ACCENT[card.finish] ?? "#39d353";
const groups = groupAwards(card.awards);
const [assets, stills] = await Promise.all([loadCardAssets(card, CARD_W), loadTrophyStills(groups)]);
const shelf = groups.length ? trophyShelf({ groups, stills, tall: 92 }) : null;
Expand Down
21 changes: 15 additions & 6 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import ScoutForm from "@/components/ScoutForm";
import type { ScoutMode } from "@/components/ScoutForm";
import CardFan from "@/components/CardFan";
import LoadingScreen from "@/components/LoadingScreen";
import OrgLoadingScreen from "@/components/OrgLoadingScreen";
import dynamic from "next/dynamic";
import FooterCredit from "@/components/FooterCredit";
import BuyMeACoffee from "@/components/BuyMeACoffee";
Expand Down Expand Up @@ -35,6 +37,7 @@ export default function AppShell({
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [pending, setPending] = useState<string | null>(null);
const [pendingMode, setPendingMode] = useState<ScoutMode>("player");
const [modalOpen, setModalOpen] = useState(false);
const [tourOpen, setTourOpen] = useState(false);

Expand Down Expand Up @@ -62,17 +65,23 @@ export default function AppShell({
return () => clearTimeout(t);
}, []);

// Scouting navigates to the canonical /<username> route. The transition keeps
// the loading screen up (with the mascot + puns) while the report is fetched
// and server-rendered; the route then plays its own reveal.
const handleScout = (name: string) => {
// Scouting navigates to the canonical /<username> route for players,
// or /org/<login> for organizations. The transition keeps the loading
// screen up while the report is fetched and server-rendered.
const handleScout = (name: string, mode: ScoutMode = "player") => {
const login = name.trim().replace(/^@/, "");
if (!login) return;
setPending(login);
startTransition(() => router.push(`/${encodeURIComponent(login)}`));
setPendingMode(mode);
const path = mode === "club" ? `/org/${encodeURIComponent(login)}` : `/${encodeURIComponent(login)}`;
startTransition(() => router.push(path));
};

if (isPending && pending) return <LoadingScreen login={pending} />;
if (isPending && pending) {
return pendingMode === "club"
? <OrgLoadingScreen login={pending} />
: <LoadingScreen login={pending} />;
}

return (
<>
Expand Down
48 changes: 48 additions & 0 deletions components/OrgLoadingScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { useEffect, useState } from "react";
import Mascot from "./Mascot";

// Loading screen for organization scouting — shows progressive status.
export default function OrgLoadingScreen({ login }: { login: string }) {
const [tick, setTick] = useState(0);

useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 2200);
return () => clearInterval(id);
}, []);

const steps = [
"Finding organization…",
"Gathering squad…",
"Scouting players…",
"Comparing formations…",
"Building best XI…",
];

return (
<main className="relative z-[2] flex h-[100dvh] flex-col items-center justify-center px-6 text-center">
<Mascot size={220} kick ball />

<div className="font-display mt-8 text-[clamp(28px,5vw,48px)] leading-none tracking-[.02em] text-ink">
SCOUTING{" "}
<span className="font-mono align-middle text-[0.5em] text-brand">{login}</span>{" "}
FC
</div>

<div className="mt-4 h-5 text-[15px] font-medium text-ink-soft" aria-live="polite">
{steps[tick % steps.length]}
</div>

{/* indeterminate progress sliver */}
<div className="mt-7 h-[3px] w-[min(260px,70vw)] overflow-hidden rounded-full bg-white/10">
<div
className="h-full w-1/3 rounded-full bg-gradient-to-r from-transparent via-brand to-transparent"
style={{ animation: "gf-load 1.3s ease-in-out infinite" }}
/>
</div>

<style>{`@keyframes gf-load{0%{transform:translateX(-120%)}100%{transform:translateX(360%)}}`}</style>
</main>
);
}
4 changes: 2 additions & 2 deletions components/PlayerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const hideOnError: React.ReactEventHandler<HTMLImageElement> = (e) => {
e.currentTarget.style.visibility = "hidden";
};

function PlayerCard({ card }: { card: Card }) {
function PlayerCard({ card, adjustedOVR }: { card: Card; adjustedOVR?: number }) {
const t = resolveCardTheme(card);
const ink = t.ink;
const displayName = cardDisplayName(card.name).toUpperCase();
Expand Down Expand Up @@ -204,7 +204,7 @@ function PlayerCard({ card }: { card: Card }) {
color: ink,
}}
>
{pad2(card.overall)}
{pad2(adjustedOVR ?? card.overall)}
</div>

{/* position (centered around the left column) */}
Expand Down
87 changes: 66 additions & 21 deletions components/ScoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { useState } from "react";
import { ArrowRight } from "lucide-react";
import Mascot from "./Mascot";

export type ScoutMode = "player" | "club";

interface Props {
loading: boolean;
error: string | null;
scoutCount: number | null;
onScout: (name: string) => void;
onScout: (name: string, mode: ScoutMode) => void;
onOpenModal: () => void;
}

Expand All @@ -23,10 +25,11 @@ export default function ScoutForm({
onOpenModal,
}: Props) {
const [name, setName] = useState("");
const [mode, setMode] = useState<ScoutMode>("player");

const submit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) onScout(name);
if (name.trim()) onScout(name, mode);
};

return (
Expand Down Expand Up @@ -59,6 +62,24 @@ export default function ScoutForm({
of 99.
</p>

{/* Player / Club toggle */}
<div className="mb-[14px] flex max-w-[460px] gap-[4px] max-[860px]:mx-auto">
{(["player", "club"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`font-display flex-1 rounded-[10px] px-[16px] py-[8px] text-[13px] font-bold tracking-[.14em] transition ${
mode === m
? "bg-brand text-[#04130a]"
: "border border-white/[0.08] bg-white/[0.025] text-ink-soft hover:bg-white/[0.05]"
}`}
>
{m === "player" ? "PLAYER" : "CLUB"}
</button>
))}
</div>

<form
onSubmit={submit}
className="m-0 flex max-w-[460px] flex-wrap gap-[10px] max-[860px]:mx-auto"
Expand All @@ -68,10 +89,10 @@ export default function ScoutForm({
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="github username"
placeholder={mode === "player" ? "github username" : "organization name"}
autoComplete="off"
spellCheck={false}
aria-label="GitHub username"
aria-label={mode === "player" ? "GitHub username" : "GitHub organization"}
className="font-mono h-14 w-full rounded-[14px] border-[1.5px] border-line bg-surface/70 pl-[34px] pr-5 text-[16px] font-medium text-white outline-none backdrop-blur-[4px] transition focus:border-brand focus:bg-surface focus:shadow-[0_0_0_4px_rgba(57,211,83,.16),0_0_42px_rgba(57,211,83,.24)]"
/>
</div>
Expand Down Expand Up @@ -101,23 +122,47 @@ export default function ScoutForm({
)}

<div className="mt-[14px] text-[13px] text-ink-mute">
try{" "}
<button
type="button"
onClick={() => onScout("torvalds")}
className={exampleClass}
>
torvalds
</button>{" "}
·{" "}
<button
type="button"
onClick={() => onScout("sindresorhus")}
className={exampleClass}
>
sindresorhus
</button>{" "}
· or your own
{mode === "player" ? (
<>
try{" "}
<button
type="button"
onClick={() => onScout("torvalds", "player")}
className={exampleClass}
>
torvalds
</button>{" "}
·{" "}
<button
type="button"
onClick={() => onScout("sindresorhus", "player")}
className={exampleClass}
>
sindresorhus
</button>{" "}
· or your own
</>
) : (
<>
try{" "}
<button
type="button"
onClick={() => onScout("vercel", "club")}
className={exampleClass}
>
vercel
</button>{" "}
·{" "}
<button
type="button"
onClick={() => onScout("github", "club")}
className={exampleClass}
>
github
</button>{" "}
· or any org
</>
)}
</div>

{/* live tally — a broadcast-style scoreboard count; the number is the
Expand Down
Loading