diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e56a4c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: + - main + - develop + - "feat/**" + - "fix/**" + - "chore/**" + pull_request: + branches: + - main + - develop + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: ["24.x"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.29.2 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm exec tsc --noEmit + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test -- --run + + - name: Build + run: pnpm build diff --git a/app/layout.tsx b/app/layout.tsx index adcab09..cf1565a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import "./globals.css"; import type { ReactNode } from "react"; +import type { Metadata } from "next"; import { cookies, headers } from "next/headers"; import Script from "next/script"; import { @@ -10,6 +11,7 @@ import { parseAcceptLanguage, supportedLocales, } from "@/lib/i18n-core"; +import { getMetadataBase, toAbsoluteUrl } from "@/lib/seo"; import Providers from "./providers"; const themeInitScript = ` @@ -30,9 +32,64 @@ const themeInitScript = ` } catch {} `; -export const metadata = { - title: "DevImpact", - description: "GitHub user scoring", +export const metadata: Metadata = { + title: { + default: "DevImpact | GitHub Developer Comparison & Open-Source Impact Scoring", + template: "%s | DevImpact", + }, + description: + "Compare GitHub developers by repository impact, merged external pull requests, and community contribution signals with transparent scoring.", + keywords: [ + "github developer comparison", + "open source impact score", + "github repository analytics", + "pull request impact", + "developer ranking tool", + "devimpact", + ], + authors: [{ name: "DevImpact Team" }], + creator: "DevImpact", + publisher: "DevImpact", + metadataBase: getMetadataBase(), + alternates: { + canonical: "/", + }, + openGraph: { + type: "website", + title: "DevImpact | GitHub Developer Comparison & Open-Source Impact Scoring", + description: + "Compare GitHub developers with transparent repo, PR, and community contribution scoring.", + url: "/", + siteName: "DevImpact", + images: [ + { + url: toAbsoluteUrl("/og-image.svg"), + width: 1200, + height: 630, + alt: "DevImpact GitHub developer comparison dashboard preview", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "DevImpact | GitHub Developer Comparison", + description: + "Compare open-source impact using repository, pull request, and community contribution signals.", + images: [toAbsoluteUrl("/og-image.svg")], + }, + robots: { + index: true, + follow: true, + nocache: false, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, + manifest: "/manifest.webmanifest", icons: { icon: "/logo.svg", shortcut: "/logo.svg", diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..7fb3167 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from "next"; +import { SITE_NAME, SITE_SHORT_NAME } from "@/lib/seo"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: SITE_NAME, + short_name: SITE_SHORT_NAME, + description: + "Compare GitHub developers with transparent open-source impact scoring.", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#0ea5e9", + icons: [ + { + src: "/logo.svg", + sizes: "any", + type: "image/svg+xml", + }, + ], + }; +} diff --git a/app/page.tsx b/app/page.tsx index 5cb1315..7c58084 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,415 +1,75 @@ -"use client"; - -import { Suspense, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { CompareForm } from "../components/compare-form"; -import { ResultDashboard } from "../components/result-dashboard"; -import { DashboardSkeleton } from "../components/skeletons"; -import { UserResult } from "@/types/user-result"; -import { BrandLogo } from "@/components/brand-logo"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; -import { useTranslation } from "@/components/language-provider"; -import { - ApiResponse, - CompareInsights, - CompareWinner, - SafeApiError, -} from "@/types/api-response"; -import { cn } from "@/lib/utils"; - -type ComparisonData = { - user1: UserResult; - user2: UserResult; - winner?: CompareWinner; - languageWinner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number; - selectedLanguages: string[]; - }; - insights?: CompareInsights; - scoreVersion?: string; +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { DashboardSkeleton } from "@/components/skeletons"; +import { HomePageClient } from "@/components/home-page-client"; +import { JsonLd } from "@/components/seo/json-ld"; +import { toAbsoluteUrl } from "@/lib/seo"; + +export const metadata: Metadata = { + title: "Compare GitHub Developers", + description: + "Compare GitHub developers side by side with transparent repository, pull request, and community contribution impact scoring.", + alternates: { + canonical: "/", + }, + openGraph: { + title: "Compare GitHub Developers | DevImpact", + description: + "Analyze repository quality, merged PR impact, and community contribution signals in one comparison dashboard.", + url: "/", + images: [ + { + url: toAbsoluteUrl("/og-image.svg"), + width: 1200, + height: 630, + alt: "DevImpact GitHub developer comparison", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Compare GitHub Developers | DevImpact", + description: + "Compare open-source impact using repository, PR, and contribution analytics.", + images: [toAbsoluteUrl("/og-image.svg")], + }, }; -type CompareOptions = { - selectedLanguages: string[]; - updateUrl?: boolean; +const websiteSchema = { + "@context": "https://schema.org", + "@type": "WebSite", + name: "DevImpact", + url: toAbsoluteUrl("/"), + potentialAction: { + "@type": "SearchAction", + target: `${toAbsoluteUrl("/")}?username={username1}&username={username2}`, + "query-input": "required name=username1", + }, }; -const EXIT_ANIMATION_MS = 240; - -function sanitizeSelectedLanguages(languages: string[]): string[] { - const seen = new Set(); - const output: string[] = []; - - for (const language of languages) { - const trimmed = language.trim(); - const normalized = trimmed.toLowerCase(); - if (!trimmed || seen.has(normalized)) { - continue; - } - output.push(trimmed); - seen.add(normalized); - if (output.length >= 5) { - break; - } - } - - return output; -} - -function normalizeUsers(body: ApiResponse): { user1: UserResult; user2: UserResult } | null { - if (body.users && body.users.length >= 2) { - return { user1: body.users[0], user2: body.users[1] }; - } - - return null; -} - -function HomePageInner() { - const { t } = useTranslation(); - const router = useRouter(); - const searchParams = useSearchParams(); - const initialUsernames = searchParams.getAll("username"); - const initialUsername1 = initialUsernames[0] ?? ""; - const initialUsername2 = initialUsernames[1] ?? ""; - const initialSelectedLanguages = sanitizeSelectedLanguages( - searchParams.getAll("selectedLanguage"), - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [username1, setUsername1] = useState(initialUsername1); - const [username2, setUsername2] = useState(initialUsername2); - const [selectedLanguages, setSelectedLanguages] = useState( - initialSelectedLanguages, - ); - const [data, setData] = useState(null); - const [displayData, setDisplayData] = useState(null); - const lastFetchedKeyRef = useRef(null); - const inFlightFetchKeyRef = useRef(null); - const inFlightPromiseRef = useRef | null>(null); - const hideTimerRef = useRef(null); - - const localizeErrorMessage = (message?: string, details?: SafeApiError) => { - if (details) { - switch (details.code) { - case "RATE_LIMITED": - return t("error.rateLimited", { - seconds: details.retryAfterSeconds ?? 60, - }); - case "TEMPORARY_THROTTLE": - return t("error.tempThrottle", { - seconds: details.retryAfterSeconds ?? 60, - }); - case "GITHUB_TIMEOUT": - return t("error.timeout"); - case "GITHUB_RESOURCE_LIMIT": - return t("error.resourceLimit"); - case "GITHUB_AUTH": - return t("error.missingToken"); - case "GITHUB_NOT_FOUND": - return t("error.userNotFound"); - case "NETWORK": - return t("error.fetchFailed"); - default: - break; - } - } - - switch (message) { - case "provide exactly two username params": - return t("error.missingUsername"); - case "GitHub user not found": - return t("error.userNotFound"); - case "Failed to calculate score": - return t("error.calculateFailed"); - case "Comparison failed": - return t("error.comparisonFailed"); - case "Failed to fetch": - return t("error.fetchFailed"); - case "Missing GITHUB_TOKEN": - return t("error.missingToken"); - default: - return t("error.generic"); - } - }; - - const createFetchKey = ( - u1: string, - u2: string, - options: CompareOptions, - ) => - JSON.stringify({ - u1, - u2, - selectedLanguages: [...sanitizeSelectedLanguages(options.selectedLanguages)].sort(), - }); - - const handleCompare = async ( - u1: string, - u2: string, - options: CompareOptions, - ) => { - const sanitizedLanguages = sanitizeSelectedLanguages(options.selectedLanguages); - const fetchKey = createFetchKey(u1, u2, options); - - if (inFlightFetchKeyRef.current === fetchKey && inFlightPromiseRef.current) { - return inFlightPromiseRef.current; - } - - lastFetchedKeyRef.current = fetchKey; - - const requestPromise = (async () => { - if (options.updateUrl !== false) { - const params = new URLSearchParams(); - params.append("username", u1); - params.append("username", u2); - for (const language of sanitizedLanguages) { - params.append("selectedLanguage", language); - } - router.push(`/?${params.toString()}`, { scroll: false }); - } - - setLoading(true); - setError(null); - - try { - const requestParams = new URLSearchParams(); - requestParams.append("username", u1); - requestParams.append("username", u2); - for (const language of sanitizedLanguages) { - requestParams.append("selectedLanguage", language); - } - - const res = await fetch(`/api/compare?${requestParams.toString()}`); - - const body: ApiResponse = await res.json(); - if (!res.ok) { - setData(null); - setError(localizeErrorMessage(body.error, body.errorDetails)); - return; - } - const users = normalizeUsers(body); - - if (!body.success || !users) { - setData(null); - setError(localizeErrorMessage(body.error || "Comparison failed", body.errorDetails)); - return; - } - - const winnerUsername = - body.winner?.username ?? - (users.user1.finalScore > users.user2.finalScore - ? users.user1.username - : users.user2.finalScore > users.user1.finalScore - ? users.user2.username - : undefined); - - const nextData: ComparisonData = { - user1: { ...users.user1, isWinner: winnerUsername === users.user1.username }, - user2: { ...users.user2, isWinner: winnerUsername === users.user2.username }, - winner: body.winner, - languageWinner: body.languageWinner, - insights: body.insights, - scoreVersion: body.scoreVersion, - }; - - setData(nextData); - setDisplayData(nextData); - } catch (err: unknown) { - setData(null); - setError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); - } finally { - if (inFlightFetchKeyRef.current === fetchKey) { - inFlightFetchKeyRef.current = null; - inFlightPromiseRef.current = null; - } - setLoading(false); - } - })(); - - inFlightFetchKeyRef.current = fetchKey; - inFlightPromiseRef.current = requestPromise; - - return requestPromise; - }; - - const syncToUrl = useEffectEvent( - (u1: string, u2: string, languages: string[]) => { - setUsername1(u1); - setUsername2(u2); - setSelectedLanguages(languages); - - if (!u1 || !u2) { - lastFetchedKeyRef.current = null; - setData(null); - setError(null); - return; - } - - const nextKey = createFetchKey(u1, u2, { - selectedLanguages: languages, - }); - - if ( - lastFetchedKeyRef.current === nextKey && - (data || inFlightFetchKeyRef.current === nextKey) - ) { - return; - } - - void handleCompare(u1, u2, { - selectedLanguages: languages, - updateUrl: false, - }); - }, - ); - - useEffect(() => { - const params = searchParams.getAll("username"); - const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); - syncToUrl( - params[0] ?? "", - params[1] ?? "", - urlLanguages, - ); - }, [searchParams]); - - useEffect(() => { - if (hideTimerRef.current !== null) { - window.clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - - if (data) { - setDisplayData(data); - return; - } - - if (loading || !displayData) { - return; - } - - hideTimerRef.current = window.setTimeout(() => { - setDisplayData(null); - hideTimerRef.current = null; - }, EXIT_ANIMATION_MS); - - return () => { - if (hideTimerRef.current !== null) { - window.clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - }; - }, [data, displayData, loading]); - - const skeleton = useMemo(() => , []); - const isRefreshing = loading && Boolean(displayData); - const isExiting = !loading && !data && Boolean(displayData); - - const reset = () => { - setLoading(false); - setData(null); - setError(null); - inFlightFetchKeyRef.current = null; - inFlightPromiseRef.current = null; - setUsername1(""); - setUsername2(""); - setSelectedLanguages([]); - router.push("/", { scroll: false }); - }; - - const swapUsers = () => { - const nextUsername1 = username2; - const nextUsername2 = username1; - - setUsername1(nextUsername1); - setUsername2(nextUsername2); - router.push( - `/?username=${encodeURIComponent(nextUsername1)}&username=${encodeURIComponent(nextUsername2)}`, - { scroll: false }, - ); - - if (!data) return; - setData((current) => (current ? { ...current, user1: current.user2, user2: current.user1 } : current)); - }; - - return ( -
- - -
- - -
- {displayData ? ( -
- -
- ) : loading ? ( -
- {skeleton} -
- ) : null} - - {loading && displayData ? ( -
-
- {t("form.compare.ing")} -
-
- ) : null} - - {!loading && !error && !displayData ? ( -
- -

{t("page.empty.title")}

-

{t("page.empty.description")}

-
- ) : null} -
-
- - -
- ); -} +const softwareSchema = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "DevImpact", + applicationCategory: "DeveloperApplication", + operatingSystem: "Web", + url: toAbsoluteUrl("/"), + description: + "DevImpact compares GitHub developers using repository, pull request, and community contribution impact signals.", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, +}; export default function HomePage() { return ( - }> - - + <> + + }> + + + ); } diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..02d6336 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,18 @@ +import type { MetadataRoute } from "next"; +import { getSiteUrl } from "@/lib/seo"; + +export default function robots(): MetadataRoute.Robots { + const siteUrl = getSiteUrl(); + + return { + rules: [ + { + userAgent: "*", + allow: ["/", "/scoring-methodology"], + disallow: ["/api/", "/_next/", "/private/"], + }, + ], + sitemap: `${siteUrl}/sitemap.xml`, + host: siteUrl, + }; +} diff --git a/app/scoring-methodology/page.tsx b/app/scoring-methodology/page.tsx index 114defb..f490eae 100644 --- a/app/scoring-methodology/page.tsx +++ b/app/scoring-methodology/page.tsx @@ -1,110 +1,74 @@ -"use client"; +import type { Metadata } from "next"; +import { JsonLd } from "@/components/seo/json-ld"; +import { ScoringMethodologyPageClient } from "@/components/scoring-methodology-page-client"; +import { toAbsoluteUrl } from "@/lib/seo"; -import { ArrowLeft } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { AppHeader } from "@/components/app-header"; -import { AppFooter } from "@/components/app-footer"; -import { useTranslation } from "@/components/language-provider"; -import { ScoringMethodologyFlow } from "@/components/scoring/scoring-methodology-flow"; -import { ScoringMethodologySection } from "@/components/scoring/scoring-methodology-section"; +export const metadata: Metadata = { + title: "Scoring Methodology", + description: + "Understand the DevImpact scoring algorithm in detail, including repo score, PR score, contribution score, normalization, and weighting.", + alternates: { + canonical: "/scoring-methodology", + }, + openGraph: { + title: "DevImpact Scoring Methodology", + description: + "Learn how DevImpact calculates GitHub developer comparison scores with transparent formulas and weighting.", + url: "/scoring-methodology", + images: [ + { + url: toAbsoluteUrl("/og-image.svg"), + width: 1200, + height: 630, + alt: "DevImpact scoring methodology overview", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "DevImpact Scoring Methodology", + description: + "Detailed breakdown of repository, pull request, contribution, and final score calculations.", + images: [toAbsoluteUrl("/og-image.svg")], + }, +}; -export default function ScoringMethodologyPage() { - const { t, dir } = useTranslation(); - const router = useRouter(); - const searchParams = useSearchParams(); - const backIconClass = dir === "rtl" ? "rotate-180" : ""; - - const handleBack = () => { - const query = searchParams.toString(); - if (query) { - router.push(`/?${query}`); - return; - } - - if (typeof window !== "undefined" && window.history.length > 1) { - router.back(); - return; - } - - router.push("/"); - }; +const faqSchema = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: [ + { + "@type": "Question", + name: "How does DevImpact calculate final score?", + acceptedAnswer: { + "@type": "Answer", + text: "DevImpact uses a weighted formula: finalScore = repoScore * 0.45 + prScore * 0.45 + contributionScore * 0.10.", + }, + }, + { + "@type": "Question", + name: "What signals are included in repository score?", + acceptedAnswer: { + "@type": "Answer", + text: "Repository score uses stars, forks, watchers, recency/activity factors, and ranking weights with diminishing impact after top repositories.", + }, + }, + { + "@type": "Question", + name: "How does DevImpact prevent score gaming?", + acceptedAnswer: { + "@type": "Answer", + text: "The algorithm applies fork penalties, PR size penalties, diminishing returns for repeated contributions, and caps contribution score so it cannot dominate the final result.", + }, + }, + ], +}; +export default function ScoringMethodologyPage() { return ( -
- -
- - -
-

{t("methodology.title")}

-

- {t("methodology.intro")} -

-
- - - -
- - - - - - -
-
- -
+ <> + + + ); } diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..e758893 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from "next"; +import { getSiteUrl } from "@/lib/seo"; + +export default function sitemap(): MetadataRoute.Sitemap { + const baseUrl = getSiteUrl(); + const now = new Date(); + + return [ + { + url: `${baseUrl}/`, + lastModified: now, + changeFrequency: "weekly", + priority: 1, + }, + { + url: `${baseUrl}/scoring-methodology`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.7, + }, + ]; +} diff --git a/components/app-header.tsx b/components/app-header.tsx index 95da047..90fd01f 100644 --- a/components/app-header.tsx +++ b/components/app-header.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { BrandLogo } from "@/components/brand-logo"; import { LanguageSwitcher } from "@/components/language-switcher"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -6,7 +7,9 @@ export function AppHeader() { return (
- + + +
diff --git a/components/compare-form.tsx b/components/compare-form.tsx index 53187df..eaaddb3 100644 --- a/components/compare-form.tsx +++ b/components/compare-form.tsx @@ -111,8 +111,11 @@ export function CompareForm({
+

+ {t("form.header.eyebrow")} +

{t("app.title")} - {t("app.subtitle")} + {t("form.enterTwo")}
diff --git a/components/home-page-client.tsx b/components/home-page-client.tsx new file mode 100644 index 0000000..cc5205f --- /dev/null +++ b/components/home-page-client.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { CompareForm } from "../components/compare-form"; +import { ResultDashboard } from "../components/result-dashboard"; +import { DashboardSkeleton } from "../components/skeletons"; +import { UserResult } from "@/types/user-result"; +import { BrandLogo } from "@/components/brand-logo"; +import { AppHeader } from "@/components/app-header"; +import { AppFooter } from "@/components/app-footer"; +import { useTranslation } from "@/components/language-provider"; +import { + ApiResponse, + CompareInsights, + CompareWinner, + SafeApiError, +} from "@/types/api-response"; +import { cn } from "@/lib/utils"; + +type ComparisonData = { + user1: UserResult; + user2: UserResult; + winner?: CompareWinner; + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + selectedLanguages: string[]; + }; + insights?: CompareInsights; + scoreVersion?: string; +}; + +type CompareOptions = { + selectedLanguages: string[]; + updateUrl?: boolean; +}; + +const EXIT_ANIMATION_MS = 240; + +function sanitizeSelectedLanguages(languages: string[]): string[] { + const seen = new Set(); + const output: string[] = []; + + for (const language of languages) { + const trimmed = language.trim(); + const normalized = trimmed.toLowerCase(); + if (!trimmed || seen.has(normalized)) { + continue; + } + output.push(trimmed); + seen.add(normalized); + if (output.length >= 5) { + break; + } + } + + return output; +} + +function normalizeUsers(body: ApiResponse): { user1: UserResult; user2: UserResult } | null { + if (body.users && body.users.length >= 2) { + return { user1: body.users[0], user2: body.users[1] }; + } + + return null; +} + +export function HomePageClient() { + const { t } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const initialUsernames = searchParams.getAll("username"); + const initialUsername1 = initialUsernames[0] ?? ""; + const initialUsername2 = initialUsernames[1] ?? ""; + const initialSelectedLanguages = sanitizeSelectedLanguages( + searchParams.getAll("selectedLanguage"), + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [username1, setUsername1] = useState(initialUsername1); + const [username2, setUsername2] = useState(initialUsername2); + const [selectedLanguages, setSelectedLanguages] = useState( + initialSelectedLanguages, + ); + const [data, setData] = useState(null); + const [displayData, setDisplayData] = useState(null); + const lastFetchedKeyRef = useRef(null); + const inFlightFetchKeyRef = useRef(null); + const inFlightPromiseRef = useRef | null>(null); + const hideTimerRef = useRef(null); + + const localizeErrorMessage = (message?: string, details?: SafeApiError) => { + if (details) { + switch (details.code) { + case "RATE_LIMITED": + return t("error.rateLimited", { + seconds: details.retryAfterSeconds ?? 60, + }); + case "TEMPORARY_THROTTLE": + return t("error.tempThrottle", { + seconds: details.retryAfterSeconds ?? 60, + }); + case "GITHUB_TIMEOUT": + return t("error.timeout"); + case "GITHUB_RESOURCE_LIMIT": + return t("error.resourceLimit"); + case "GITHUB_AUTH": + return t("error.missingToken"); + case "GITHUB_NOT_FOUND": + return t("error.userNotFound"); + case "NETWORK": + return t("error.fetchFailed"); + default: + break; + } + } + + switch (message) { + case "provide exactly two username params": + return t("error.missingUsername"); + case "GitHub user not found": + return t("error.userNotFound"); + case "Failed to calculate score": + return t("error.calculateFailed"); + case "Comparison failed": + return t("error.comparisonFailed"); + case "Failed to fetch": + return t("error.fetchFailed"); + case "Missing GITHUB_TOKEN": + return t("error.missingToken"); + default: + return t("error.generic"); + } + }; + + const createFetchKey = ( + u1: string, + u2: string, + options: CompareOptions, + ) => + JSON.stringify({ + u1, + u2, + selectedLanguages: [...sanitizeSelectedLanguages(options.selectedLanguages)].sort(), + }); + + const handleCompare = async ( + u1: string, + u2: string, + options: CompareOptions, + ) => { + const sanitizedLanguages = sanitizeSelectedLanguages(options.selectedLanguages); + const fetchKey = createFetchKey(u1, u2, options); + + if (inFlightFetchKeyRef.current === fetchKey && inFlightPromiseRef.current) { + return inFlightPromiseRef.current; + } + + lastFetchedKeyRef.current = fetchKey; + + const requestPromise = (async () => { + if (options.updateUrl !== false) { + const params = new URLSearchParams(); + params.append("username", u1); + params.append("username", u2); + for (const language of sanitizedLanguages) { + params.append("selectedLanguage", language); + } + router.push(`/?${params.toString()}`, { scroll: false }); + } + + setLoading(true); + setError(null); + + try { + const requestParams = new URLSearchParams(); + requestParams.append("username", u1); + requestParams.append("username", u2); + for (const language of sanitizedLanguages) { + requestParams.append("selectedLanguage", language); + } + + const res = await fetch(`/api/compare?${requestParams.toString()}`); + + const body: ApiResponse = await res.json(); + if (!res.ok) { + setData(null); + setError(localizeErrorMessage(body.error, body.errorDetails)); + return; + } + const users = normalizeUsers(body); + + if (!body.success || !users) { + setData(null); + setError(localizeErrorMessage(body.error || "Comparison failed", body.errorDetails)); + return; + } + + const winnerUsername = + body.winner?.username ?? + (users.user1.finalScore > users.user2.finalScore + ? users.user1.username + : users.user2.finalScore > users.user1.finalScore + ? users.user2.username + : undefined); + + const nextData: ComparisonData = { + user1: { ...users.user1, isWinner: winnerUsername === users.user1.username }, + user2: { ...users.user2, isWinner: winnerUsername === users.user2.username }, + winner: body.winner, + languageWinner: body.languageWinner, + insights: body.insights, + scoreVersion: body.scoreVersion, + }; + + setData(nextData); + setDisplayData(nextData); + } catch (err: unknown) { + setData(null); + setError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); + } finally { + if (inFlightFetchKeyRef.current === fetchKey) { + inFlightFetchKeyRef.current = null; + inFlightPromiseRef.current = null; + } + setLoading(false); + } + })(); + + inFlightFetchKeyRef.current = fetchKey; + inFlightPromiseRef.current = requestPromise; + + return requestPromise; + }; + + const syncToUrl = useEffectEvent( + (u1: string, u2: string, languages: string[]) => { + setUsername1(u1); + setUsername2(u2); + setSelectedLanguages(languages); + + if (!u1 || !u2) { + lastFetchedKeyRef.current = null; + setData(null); + setError(null); + return; + } + + const nextKey = createFetchKey(u1, u2, { + selectedLanguages: languages, + }); + + if ( + lastFetchedKeyRef.current === nextKey && + (data || inFlightFetchKeyRef.current === nextKey) + ) { + return; + } + + void handleCompare(u1, u2, { + selectedLanguages: languages, + updateUrl: false, + }); + }, + ); + + useEffect(() => { + const params = searchParams.getAll("username"); + const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); + syncToUrl( + params[0] ?? "", + params[1] ?? "", + urlLanguages, + ); + }, [searchParams]); + + useEffect(() => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + + if (data) { + setDisplayData(data); + return; + } + + if (loading || !displayData) { + return; + } + + hideTimerRef.current = window.setTimeout(() => { + setDisplayData(null); + hideTimerRef.current = null; + }, EXIT_ANIMATION_MS); + + return () => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [data, displayData, loading]); + + const skeleton = useMemo(() => , []); + const isRefreshing = loading && Boolean(displayData); + const isExiting = !loading && !data && Boolean(displayData); + + const reset = () => { + setLoading(false); + setData(null); + setError(null); + inFlightFetchKeyRef.current = null; + inFlightPromiseRef.current = null; + setUsername1(""); + setUsername2(""); + setSelectedLanguages([]); + router.push("/", { scroll: false }); + }; + + const swapUsers = () => { + const nextUsername1 = username2; + const nextUsername2 = username1; + + setUsername1(nextUsername1); + setUsername2(nextUsername2); + router.push( + `/?username=${encodeURIComponent(nextUsername1)}&username=${encodeURIComponent(nextUsername2)}`, + { scroll: false }, + ); + + if (!data) return; + setData((current) => (current ? { ...current, user1: current.user2, user2: current.user1 } : current)); + }; + + return ( +
+ + +
+ + +
+ {displayData ? ( +
+ +
+ ) : loading ? ( +
+ {skeleton} +
+ ) : null} + + {loading && displayData ? ( +
+
+ {t("form.compare.ing")} +
+
+ ) : null} + + {!loading && !error && !displayData ? ( +
+ +

{t("page.empty.title")}

+

{t("page.empty.description")}

+
+ ) : null} +
+
+ + +
+ ); +} diff --git a/components/scoring-methodology-page-client.tsx b/components/scoring-methodology-page-client.tsx new file mode 100644 index 0000000..76f9ed1 --- /dev/null +++ b/components/scoring-methodology-page-client.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { AppHeader } from "@/components/app-header"; +import { AppFooter } from "@/components/app-footer"; +import { useTranslation } from "@/components/language-provider"; +import { ScoringMethodologyFlow } from "@/components/scoring/scoring-methodology-flow"; +import { ScoringMethodologySection } from "@/components/scoring/scoring-methodology-section"; + +export function ScoringMethodologyPageClient() { + const { t, dir } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + const backIconClass = dir === "rtl" ? "rotate-180" : ""; + + const handleBack = () => { + const query = searchParams.toString(); + if (query) { + router.push(`/?${query}`); + return; + } + + if (typeof window !== "undefined" && window.history.length > 1) { + router.back(); + return; + } + + router.push("/"); + }; + + return ( +
+ +
+ + +
+

{t("methodology.title")}

+

+ {t("methodology.intro")} +

+
+ + + +
+ + + + + + +
+
+ +
+ ); +} diff --git a/components/seo/json-ld.tsx b/components/seo/json-ld.tsx new file mode 100644 index 0000000..fdef8f2 --- /dev/null +++ b/components/seo/json-ld.tsx @@ -0,0 +1,13 @@ +type JsonLdProps = { + data: Record | Record[]; +}; + +export function JsonLd({ data }: JsonLdProps) { + return ( +