diff --git a/app/api/org-info/route.ts b/app/api/org-info/route.ts
new file mode 100644
index 0000000..4b1231f
--- /dev/null
+++ b/app/api/org-info/route.ts
@@ -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 });
+ }
+}
diff --git a/app/api/org-scout/route.ts b/app/api/org-scout/route.ts
new file mode 100644
index 0000000..c052d66
--- /dev/null
+++ b/app/api/org-scout/route.ts
@@ -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 });
+ }
+}
diff --git a/app/org/[login]/page.tsx b/app/org/[login]/page.tsx
new file mode 100644
index 0000000..247810c
--- /dev/null
+++ b/app/org/[login]/page.tsx
@@ -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 {
+ 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 (
+
+ CLUB SCOUT
+
+ Club not found
+
+ {message}
+
+ BACK TO THE BENCH
+
+
+ );
+}
+
+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 (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/app/u/[username]/opengraph-image.tsx b/app/u/[username]/opengraph-image.tsx
index 3575645..3af0163 100644
--- a/app/u/[username]/opengraph-image.tsx
+++ b/app/u/[username]/opengraph-image.tsx
@@ -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;
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index 35e2e0a..e2d16c3 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -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";
@@ -35,6 +37,7 @@ export default function AppShell({
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [pending, setPending] = useState(null);
+ const [pendingMode, setPendingMode] = useState("player");
const [modalOpen, setModalOpen] = useState(false);
const [tourOpen, setTourOpen] = useState(false);
@@ -62,17 +65,23 @@ export default function AppShell({
return () => clearTimeout(t);
}, []);
- // Scouting navigates to the canonical / 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 / route for players,
+ // or /org/ 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 ;
+ if (isPending && pending) {
+ return pendingMode === "club"
+ ?
+ : ;
+ }
return (
<>
diff --git a/components/OrgLoadingScreen.tsx b/components/OrgLoadingScreen.tsx
new file mode 100644
index 0000000..acfcefe
--- /dev/null
+++ b/components/OrgLoadingScreen.tsx
@@ -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 (
+
+
+
+
+ SCOUTING{" "}
+ {login}{" "}
+ FC
+
+
+
+ {steps[tick % steps.length]}
+
+
+ {/* indeterminate progress sliver */}
+
+
+
+
+ );
+}
diff --git a/components/PlayerCard.tsx b/components/PlayerCard.tsx
index 114930d..97bc82c 100644
--- a/components/PlayerCard.tsx
+++ b/components/PlayerCard.tsx
@@ -63,7 +63,7 @@ const hideOnError: React.ReactEventHandler = (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();
@@ -204,7 +204,7 @@ function PlayerCard({ card }: { card: Card }) {
color: ink,
}}
>
- {pad2(card.overall)}
+ {pad2(adjustedOVR ?? card.overall)}
{/* position (centered around the left column) */}
diff --git a/components/ScoutForm.tsx b/components/ScoutForm.tsx
index 50a5d13..200b0ad 100644
--- a/components/ScoutForm.tsx
+++ b/components/ScoutForm.tsx
@@ -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;
}
@@ -23,10 +25,11 @@ export default function ScoutForm({
onOpenModal,
}: Props) {
const [name, setName] = useState("");
+ const [mode, setMode] = useState("player");
const submit = (e: React.FormEvent) => {
e.preventDefault();
- if (name.trim()) onScout(name);
+ if (name.trim()) onScout(name, mode);
};
return (
@@ -59,6 +62,24 @@ export default function ScoutForm({
of 99.
+ {/* Player / Club toggle */}
+
+ {(["player", "club"] as const).map((m) => (
+
+ ))}
+
+