From 1c276dfaf2ea607348bb9a1d2a6b56f496261726 Mon Sep 17 00:00:00 2001 From: ws-rush Date: Sat, 29 Aug 2026 10:13:49 +0300 Subject: [PATCH 1/4] feat(profile): add dedicated developer profile pages and API - Add /user/[username] dynamic route with SSR metadata and JSON-LD - Create UserProfileClient dashboard with score breakdown, top work, and transparency signals - Add /api/user/[username] endpoint and getUserProfile utility with automatic DB syncing - Update leaderboard and comparison views to link to developer profiles - Add full i18n support (EN/AR) and comprehensive unit tests --- app/api/user/[username]/route.ts | 104 +++++ app/user/[username]/loading.tsx | 15 + app/user/[username]/page.tsx | 171 +++++++ components/home-page-client.tsx | 23 +- components/leaderboard-table.tsx | 42 +- components/result-dashboard.tsx | 150 ++++--- components/top-list.tsx | 23 +- components/user-not-found.tsx | 43 ++ components/user-profile-client.tsx | 645 +++++++++++++++++++++++++++ components/user-profile-skeleton.tsx | 92 ++++ lib/leaderboard.ts | 90 ++-- lib/user.ts | 116 +++++ locales/ar.json | 25 +- locales/en.json | 25 +- scripts/init-db.ts | 1 + test/api/user.route.test.ts | 212 +++++++++ 16 files changed, 1667 insertions(+), 110 deletions(-) create mode 100644 app/api/user/[username]/route.ts create mode 100644 app/user/[username]/loading.tsx create mode 100644 app/user/[username]/page.tsx create mode 100644 components/user-not-found.tsx create mode 100644 components/user-profile-client.tsx create mode 100644 components/user-profile-skeleton.tsx create mode 100644 lib/user.ts create mode 100644 test/api/user.route.test.ts diff --git a/app/api/user/[username]/route.ts b/app/api/user/[username]/route.ts new file mode 100644 index 0000000..4f90321 --- /dev/null +++ b/app/api/user/[username]/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server"; +import { getUserProfile, UserFetchError } from "@/lib/user"; +import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring"; +import { toSafeApiError } from "@/lib/github-graphql-client"; +import type { SafeApiError } from "@/types/api-response"; + +export const runtime = "nodejs"; + +type ClientSafeError = Pick; + +function parseSelectedLanguagesFromSearchParams(searchParams: URLSearchParams): string[] { + const fromRepeated = searchParams.getAll("selectedLanguage"); + const fromCsv = searchParams + .get("selectedLanguages") + ?.split(",") + .map((language) => language.trim()) + .filter(Boolean); + + return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]); +} + +function toClientSafeError(error: SafeApiError): ClientSafeError { + return { + code: error.code, + message: error.message, + targetUsernames: error.targetUsernames, + }; +} + +function toApiErrorStatus(code: ReturnType["code"]): number { + switch (code) { + case "RATE_LIMITED": + case "TEMPORARY_THROTTLE": + return 429; + case "GITHUB_TIMEOUT": + case "GITHUB_RESOURCE_LIMIT": + case "GITHUB_AUTH": + return code === "GITHUB_AUTH" ? 401 : 503; + case "GITHUB_NOT_FOUND": + return 404; + case "NETWORK": + return 503; + case "UNKNOWN": + default: + return 500; + } +} + +export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) { + const { username } = await params; + const trimmed = username?.trim(); + + if (!trimmed) { + return NextResponse.json( + { success: false, error: "Username parameter is required" }, + { status: 400 }, + ); + } + + const { searchParams } = new URL(request.url); + const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams); + + try { + const { user, location } = await getUserProfile(trimmed, selectedLanguages); + return NextResponse.json({ success: true, user, location }); + } catch (error: unknown) { + console.error("User profile fetch error:", error); + + let safeError: SafeApiError; + + if (error instanceof UserFetchError) { + const mappedCause = toSafeApiError(error.causeError); + if ( + mappedCause.code === "GITHUB_NOT_FOUND" || + (error.causeError instanceof Error && error.causeError.message === "User not found") + ) { + safeError = { + code: "GITHUB_NOT_FOUND", + message: "GitHub user not found", + targetUsernames: [error.username], + rateLimit: mappedCause.rateLimit, + }; + } else { + safeError = mappedCause; + } + } else { + safeError = + error instanceof Error && error.message === "User not found" + ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } + : toSafeApiError(error); + } + + const clientSafeError = toClientSafeError(safeError); + + return NextResponse.json( + { + success: false, + error: clientSafeError.message, + errorDetails: clientSafeError, + }, + { status: toApiErrorStatus(safeError.code) }, + ); + } +} diff --git a/app/user/[username]/loading.tsx b/app/user/[username]/loading.tsx new file mode 100644 index 0000000..651af70 --- /dev/null +++ b/app/user/[username]/loading.tsx @@ -0,0 +1,15 @@ +import { AppHeader } from "@/components/app-header"; +import { AppFooter } from "@/components/app-footer"; +import { UserProfileSkeleton } from "@/components/user-profile-skeleton"; + +export default function UserProfileLoading() { + return ( +
+ +
+ +
+ +
+ ); +} diff --git a/app/user/[username]/page.tsx b/app/user/[username]/page.tsx new file mode 100644 index 0000000..7e1b69a --- /dev/null +++ b/app/user/[username]/page.tsx @@ -0,0 +1,171 @@ +import type { Metadata } from "next"; +import { JsonLd } from "@/components/seo/json-ld"; +import { UserProfileClient } from "@/components/user-profile-client"; +import { UserNotFoundCard } from "@/components/user-not-found"; +import { AppHeader } from "@/components/app-header"; +import { AppFooter } from "@/components/app-footer"; +import { getUserProfile } from "@/lib/user"; +import { toAbsoluteUrl } from "@/lib/seo"; + +type Props = { + params: Promise<{ username: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { username } = await params; + const cleanUsername = decodeURIComponent(username.trim()); + + let displayName = cleanUsername; + try { + const { user } = await getUserProfile(cleanUsername); + displayName = user.name?.trim() || cleanUsername; + } catch { + // Fallback if user cannot be fetched during metadata generation + } + + const pageTitle = `${displayName} (@${cleanUsername}) - Developer Impact & Stats`; + const description = `Explore ${displayName}'s (@${cleanUsername}) open-source developer impact score, top repositories, merged pull requests, and community contributions on DevImpact.`; + const pageUrl = `/user/${cleanUsername}`; + + return { + title: pageTitle, + description, + keywords: [ + `${cleanUsername} GitHub`, + `${displayName} developer stats`, + `${cleanUsername} open source impact`, + "developer impact score", + "GitHub profile analytics", + ], + alternates: { + canonical: pageUrl, + }, + openGraph: { + type: "profile", + title: `${pageTitle} | DevImpact`, + description, + url: pageUrl, + images: [ + { + url: toAbsoluteUrl("/og-image.svg"), + width: 1200, + height: 630, + alt: `${displayName} GitHub developer impact score preview`, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: `${pageTitle} | DevImpact`, + description, + images: [toAbsoluteUrl("/og-image.svg")], + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, + }; +} + +export default async function UserProfilePage({ params }: Props) { + const { username } = await params; + const cleanUsername = decodeURIComponent(username.trim()); + const profileUrl = toAbsoluteUrl(`/user/${cleanUsername}`); + + let profileData: Awaited> | null = null; + let fetchErrorMessage: string | null = null; + + try { + profileData = await getUserProfile(cleanUsername); + } catch (err: unknown) { + fetchErrorMessage = err instanceof Error ? err.message : "Failed to load user profile"; + } + + if (!profileData || fetchErrorMessage) { + return ( +
+ +
+ +
+ +
+ ); + } + + const { user, location } = profileData; + const displayName = user.name?.trim() || user.username; + + const profilePageSchema = { + "@context": "https://schema.org", + "@type": "ProfilePage", + name: `${displayName} Developer Profile`, + description: `Open-source impact statistics and scoring for ${displayName} (@${user.username}).`, + url: profileUrl, + mainEntity: { + "@type": "Person", + name: displayName, + alternateName: user.username, + image: user.avatarUrl, + url: `https://github.com/${user.username}`, + ...(location ? { homeLocation: location } : {}), + interactionStatistic: [ + { + "@type": "InteractionCounter", + interactionType: "https://schema.org/LikeAction", + userInteractionCount: user.finalScore, + }, + ], + }, + isPartOf: { + "@type": "WebSite", + name: "DevImpact", + url: toAbsoluteUrl("/"), + }, + }; + + const breadcrumbSchema = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: toAbsoluteUrl("/"), + }, + { + "@type": "ListItem", + position: 2, + name: "Leaderboards", + item: toAbsoluteUrl("/leaderboard"), + }, + { + "@type": "ListItem", + position: 3, + name: displayName, + item: profileUrl, + }, + ], + }; + + return ( +
+ +
+ + + + +
+ +
+ ); +} diff --git a/components/home-page-client.tsx b/components/home-page-client.tsx index 5680925..dd723b5 100644 --- a/components/home-page-client.tsx +++ b/components/home-page-client.tsx @@ -55,13 +55,26 @@ function normalizeUsers(body: ApiResponse): { user1: UserResult; user2: UserResu return null; } +function parseUsernamesFromSearchParams(searchParams: { + getAll: (name: string) => string[]; + get: (name: string) => string | null; +}): [string, string] { + const repeated = searchParams + .getAll("username") + .map((u) => u.trim()) + .filter(Boolean); + const u1 = + repeated[0] || searchParams.get("username1")?.trim() || searchParams.get("user1")?.trim() || ""; + const u2 = + repeated[1] || searchParams.get("username2")?.trim() || searchParams.get("user2")?.trim() || ""; + return [u1, u2]; +} + 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 [initialUsername1, initialUsername2] = parseUsernamesFromSearchParams(searchParams); const initialSelectedLanguages = sanitizeSelectedLanguages( searchParams.getAll("selectedLanguage"), ); @@ -334,10 +347,10 @@ export function HomePageClient() { }); useEffect(() => { - const params = searchParams.getAll("username"); + const [u1, u2] = parseUsernamesFromSearchParams(searchParams); const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); queueMicrotask(() => { - syncToUrl(params[0] ?? "", params[1] ?? "", urlLanguages); + syncToUrl(u1, u2, urlLanguages); }); }, [searchParams]); diff --git a/components/leaderboard-table.tsx b/components/leaderboard-table.tsx index d56ecd3..631af93 100644 --- a/components/leaderboard-table.tsx +++ b/components/leaderboard-table.tsx @@ -1,7 +1,9 @@ "use client"; import { useState, useMemo } from "react"; -import { Search, AlertTriangle } from "lucide-react"; +import Link from "next/link"; +import type { Route } from "next"; +import { Search, AlertTriangle, ExternalLink } from "lucide-react"; import { Avatar } from "./avatar"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card"; @@ -146,17 +148,35 @@ export function LeaderboardTable({
- + + +
- - {user.name || user.username} - -

{user.username}

+
+ + {user.name || user.username} + + + + +
+

@{user.username}

diff --git a/components/result-dashboard.tsx b/components/result-dashboard.tsx index 95b0e8e..44ea05d 100644 --- a/components/result-dashboard.tsx +++ b/components/result-dashboard.tsx @@ -1,7 +1,9 @@ "use client"; import { useMemo, useState } from "react"; -import { Check, Copy, Trophy } from "lucide-react"; +import Link from "next/link"; +import type { Route } from "next"; +import { Check, Copy, ExternalLink, Trophy } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { Avatar } from "@/components/avatar"; import { ComparisonChart } from "./comparison-chart"; @@ -150,20 +152,31 @@ export function ResultDashboard({
- - - {title} - + + + +
+ + {title} + + + + +
{isWinner ? ( @@ -214,6 +227,15 @@ export function ResultDashboard({

) : null} +
+ + {t("profile.viewFullStats")} + + +
); @@ -270,20 +292,31 @@ export function ResultDashboard({ - - - {getDisplayName(user)} - + + + +
+ + {getDisplayName(user)} + + + + +
@@ -314,17 +347,25 @@ export function ResultDashboard({

{t("banner.winner")}

- - {getDisplayName(overallWinnerUser)} - +
+ + {getDisplayName(overallWinnerUser)} + + + + +
@@ -361,24 +402,33 @@ export function ResultDashboard({ return (
{winnerAvatar ? ( - + + + ) : null} -

- {t("banner.languageWinner")}:{" "} +

+ {t("banner.languageWinner")}: + + {winnerName} + - {winnerName} + -

+
); })()} diff --git a/components/top-list.tsx b/components/top-list.tsx index 4e87596..f16027c 100644 --- a/components/top-list.tsx +++ b/components/top-list.tsx @@ -1,7 +1,10 @@ import type { ReactNode } from "react"; +import Link from "next/link"; +import type { Route } from "next"; import { ArrowDown, ArrowUp, + ExternalLink, Eye, GitFork, GitPullRequest, @@ -185,8 +188,24 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { {userResults.map((user, idx) => ( - - {t("topwork.titleForUser", { username: user.name || user.username })} + +
+ + {t("topwork.titleForUser", { username: user.name || user.username })} + + + + +
{t("topwork.desc")}
diff --git a/components/user-not-found.tsx b/components/user-not-found.tsx new file mode 100644 index 0000000..a47e15d --- /dev/null +++ b/components/user-not-found.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, Search, Trophy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslation } from "@/components/language-provider"; + +type Props = { + username: string; +}; + +export function UserNotFoundCard({ username }: Props) { + const { t } = useTranslation(); + + return ( + + +
+ +
+ {t("profile.notFound.title")} + + {t("profile.notFound.description", { username })} + +
+ + + + + + + + +
+ ); +} diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx new file mode 100644 index 0000000..bf8950e --- /dev/null +++ b/components/user-profile-client.tsx @@ -0,0 +1,645 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import type { Route } from "next"; +import { + ArrowLeft, + Check, + Copy, + ExternalLink, + GitFork, + GitPullRequest, + MapPin, + MessageSquare, + Scale, + ShieldCheck, + Star, + Trophy, +} from "lucide-react"; +import { Avatar } from "@/components/avatar"; +import { ScoreCard } from "@/components/score-card"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useTranslation } from "@/components/language-provider"; +import { getCountryCode } from "@/lib/country-flags"; +import { detectCountry } from "@/lib/location-detector"; +import type { UserResult } from "@/types/user-result"; + +type Props = { + user: UserResult; + location?: string | null; +}; + +type LanguageEntry = { + name: string; + percentage: number; +}; + +function getLanguageColor(name: string): string { + const normalized = name.trim().toLowerCase(); + if (normalized === "typescript") return "bg-sky-500"; + if (normalized === "javascript") return "bg-amber-400"; + if (normalized === "python") return "bg-blue-500"; + if (normalized === "go") return "bg-cyan-500"; + if (normalized === "rust") return "bg-orange-500"; + if (normalized === "java") return "bg-red-500"; + if (normalized === "c#") return "bg-violet-500"; + if (normalized === "php") return "bg-indigo-500"; + if (normalized === "ruby") return "bg-rose-500"; + if (normalized === "swift") return "bg-orange-400"; + if (normalized === "kotlin") return "bg-fuchsia-500"; + if (normalized === "c++") return "bg-blue-700"; + return "bg-slate-500"; +} + +function StatChip({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) { + return ( +
+ {icon} + {label} + {value} +
+ ); +} + +function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) { + if (!topLanguages || topLanguages.length === 0) return null; + + const normalized = topLanguages.slice(0, 4).filter((lang) => lang.percentage > 0); + + if (normalized.length === 0) return null; + + return ( +
+
+ {normalized.map((lang, idx) => ( +
+ ))} +
+
+ {normalized.map((lang, idx) => ( + + + {lang.name} {Math.round(lang.percentage * 100)}% + + ))} +
+
+ ); +} + +export function UserProfileClient({ user, location }: Props) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const displayName = user.name?.trim() || user.username; + const githubUrl = `https://github.com/${user.username}`; + const compareUrl = `/?user1=${encodeURIComponent(user.username)}`; + + // Location & Country flag detection + const detectedSlug = detectCountry(location ?? null); + const flagCode = detectedSlug ? getCountryCode(detectedSlug) : null; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + }; + + // Signal stats entries for transparency + const signalEntries = user.signals + ? [ + { label: t("signals.reposAnalyzed"), value: user.signals.reposAnalyzed ?? "-" }, + { + label: t("signals.pullRequestsAnalyzed"), + value: user.signals.pullRequestsAnalyzed ?? "-", + }, + { + label: t("signals.mergedExternalPRs"), + value: user.signals.mergedExternalPRs ?? "-", + }, + { + label: t("signals.ownRepoPRsIgnored"), + value: user.signals.ownRepoPRsIgnored ?? "-", + }, + { + label: t("signals.unmergedPRsIgnored"), + value: user.signals.unmergedPRsIgnored ?? "-", + }, + { + label: t("signals.uniqueExternalPRRepos"), + value: user.signals.uniqueExternalPRRepos ?? "-", + }, + { + label: t("signals.issuesAnalyzed"), + value: user.signals.issuesAnalyzed ?? "-", + }, + { + label: t("signals.externalIssuesCounted"), + value: user.signals.externalIssuesCounted ?? "-", + }, + { + label: t("signals.discussionsAnalyzed"), + value: user.signals.discussionsAnalyzed ?? "-", + }, + { + label: t("signals.externalDiscussionsCounted"), + value: user.signals.externalDiscussionsCounted ?? "-", + }, + ] + : []; + + const totalRawSum = Math.max( + 1, + user.repoScore * 0.45 + user.prScore * 0.45 + user.contributionScore * 0.1, + ); + const repoWeightPct = Math.round(((user.repoScore * 0.45) / totalRawSum) * 100); + const prWeightPct = Math.round(((user.prScore * 0.45) / totalRawSum) * 100); + const contributionWeightPct = Math.max(0, 100 - repoWeightPct - prWeightPct); + + return ( +
+ {/* ── Back Navigation ────────────────────────────────────────── */} +
+ + + +
+ + {/* ── Header Profile Hero Section ────────────────────────────── */} +
+
+
+ +
+

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

+
+

+ {displayName} +

+ + @{user.username} + +
+ + {location ? ( +
+ {flagCode ? ( + + ) : ( + + )} + {location} +
+ ) : null} + +
+ + GitHub + + + {user.scoreVersion ? ( + + • {t("results.scoreVersion")}: {user.scoreVersion} + + ) : null} +
+
+
+ + {/* Actions */} +
+ + + + + +
+
+
+ + {/* ── Score Cards Grid ────────────────────────────────────────── */} +
+
+

+ {t("profile.scoreOverview")} +

+

+ {t("profile.title")} +

+
+ +
+ + + + +
+
+ + {/* ── Score Weighting Distribution ────────────────────────────── */} + + +
+ + {t("profile.scoreDistribution")} +
+ {t("methodology.sections.weights.formula")} +
+ +
+
+ + + {t("breakdown.repo")} (45%) + + + {t("profile.signalShare", { pct: repoWeightPct })} + +
+ +
+ +
+
+ + + {t("breakdown.pr")} (45%) + + + {t("profile.signalShare", { pct: prWeightPct })} + +
+ +
+ +
+
+ + + {t("breakdown.contribution")} (10%) + + + {t("profile.signalShare", { pct: contributionWeightPct })} + +
+ +
+
+
+ + {/* ── Top Work Section ────────────────────────────────────────── */} +
+
+

+ {t("topwork.title")} +

+

+ {t("profile.topWork")} +

+
+ +
+ {/* Top Repositories */} + + + + + {t("topwork.toprepos")} + + {t("topwork.desc")} + + + {user.topRepos.length === 0 ? ( +

{t("empty.repos")}

+ ) : ( + user.topRepos.slice(0, 3).map((repo, idx) => ( +
+
+
+
+ + #{idx + 1} + + {repo.url ? ( + + {repo.name || t("untitled")} + + ) : ( +

{repo.name || t("untitled")}

+ )} +
+ +
+ } + label={t("topwork.stars")} + value={repo.stars ?? 0} + /> + } + label={t("topwork.forks")} + value={repo.forks ?? 0} + /> +
+ + +
+ +
+

{repo.score ?? 0}

+

{t("comparsion.score")}

+
+
+
+ )) + )} +
+
+ + {/* Top Pull Requests */} + + + + + {t("topwork.topprs")} + + {t("topwork.desc")} + + + {user.topPullRequests.length === 0 ? ( +

{t("empty.pullRequests")}

+ ) : ( + user.topPullRequests.slice(0, 3).map((pr, idx) => ( +
+
+
+
+ + #{idx + 1} + + {pr.url ? ( + + {pr.title || t("untitled")} + + ) : ( +

{pr.title || t("untitled")}

+ )} +
+ +

+ {t("topwork.inRepo", { + repo: pr.repo || t("unknown.repo"), + })} +

+ +
+ } + label={t("topwork.pr.repo.stars")} + value={pr.stars ?? 0} + /> +
+ + +{pr.additions ?? 0} + + / + + -{pr.deletions ?? 0} + +
+
+ + +
+ +
+

{pr.score ?? 0}

+

{t("comparsion.score")}

+
+
+
+ )) + )} +
+
+ + {/* Top Community Contributions */} + + + + + {t("community.title")} + + {t("community.comments")} + + + {user.topCommunityContributions && user.topCommunityContributions.length > 0 ? ( + user.topCommunityContributions.slice(0, 3).map((item, idx) => ( +
+
+
+
+ + #{idx + 1} + + + {item.type === "issue" + ? t("community.issue") + : t("community.discussion")} + +
+ + {item.url ? ( + + {item.title} + + ) : ( +

{item.title}

+ )} + +

+ {item.repo} +

+ +
+ } + label={t("topwork.stars")} + value={item.stars} + /> + } + label={t("community.comments")} + value={item.comments} + /> +
+
+ +
+

{item.score}

+

{t("comparsion.score")}

+
+
+
+ )) + ) : ( +

{t("empty.community")}

+ )} +
+
+
+
+ + {/* ── Transparency Signals ────────────────────────────────────── */} + {signalEntries.length > 0 ? ( +
+ +
+ + {t("signals.title")} +
+ + ▼ + +
+
+ {signalEntries.map((entry) => ( +
+

{entry.label}

+

{entry.value}

+
+ ))} +
+
+ ) : null} + + {/* ── Scoring Methodology CTA ─────────────────────────────────── */} + + + + + {t("explanations.title")} + + + +

{t("methodology.cta.description")}

+ + {t("methodology.cta.button")} + +
+
+
+ ); +} diff --git a/components/user-profile-skeleton.tsx b/components/user-profile-skeleton.tsx new file mode 100644 index 0000000..8fc5965 --- /dev/null +++ b/components/user-profile-skeleton.tsx @@ -0,0 +1,92 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; + +export function UserProfileSkeleton() { + return ( +
+ {/* Header Profile Skeleton */} + + +
+
+ +
+ + + +
+
+
+ + +
+
+
+
+ + {/* Score Cards Skeleton */} +
+ + + + +
+ + {/* Score Distribution Bars Skeleton */} + + + + + + + + + + + + + + {/* Top Work Cards Skeleton */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts index ee13438..22aa93e 100644 --- a/lib/leaderboard.ts +++ b/lib/leaderboard.ts @@ -64,52 +64,62 @@ export async function getLeaderboardResult(country: string): Promise ({ + username: row.username, + name: row.name, + avatarUrl: row.avatar_url, + repoScore: row.repo_score, + prScore: row.pr_score, + contributionScore: row.contribution_score, + finalScore: row.final_score, + impactRank: 0, + })); + + scored.sort((a, b) => b.finalScore - a.finalScore); + scored.forEach((user, index) => { + user.impactRank = index + 1; + }); + + const result: LeaderboardResult = { + title: country, + totalFromSource: totalCount, + scored, + errors: [], + }; - if (rows.length === 0) { + if (cacheStore.enabled) { + try { + const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace); + await cacheStore.set(cacheKey, result, cacheConfig.ttlSeconds); + } catch { + // Cache write failures should not block the page/API response. + } + } + + return result; + } catch (err: unknown) { + console.warn("Leaderboard database query error:", err); return { title: country, totalFromSource: 0, scored: [], - errors: [], + errors: [err instanceof Error ? err.message : "Database unavailable"], }; } - - const scored: ScoredLeaderboardEntry[] = rows.map((row) => ({ - username: row.username, - name: row.name, - avatarUrl: row.avatar_url, - repoScore: row.repo_score, - prScore: row.pr_score, - contributionScore: row.contribution_score, - finalScore: row.final_score, - impactRank: 0, - })); - - scored.sort((a, b) => b.finalScore - a.finalScore); - scored.forEach((user, index) => { - user.impactRank = index + 1; - }); - - const result: LeaderboardResult = { - title: country, - totalFromSource: totalCount, - scored, - errors: [], - }; - - if (cacheStore.enabled) { - try { - const cacheKey = buildLeaderboardCacheKey(country, cacheConfig.namespace); - await cacheStore.set(cacheKey, result, cacheConfig.ttlSeconds); - } catch { - // Cache write failures should not block the page/API response. - } - } - - return result; } diff --git a/lib/user.ts b/lib/user.ts new file mode 100644 index 0000000..22c151d --- /dev/null +++ b/lib/user.ts @@ -0,0 +1,116 @@ +import { getUserData } from "@/lib/github"; +import { calculateUserScore } from "@/lib/score"; +import { getDatabaseStore } from "@/lib/db-store"; +import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache-store"; +import { detectCountry } from "@/lib/location-detector"; +import type { UserResult } from "@/types/user-result"; +import type { GitHubUserData } from "@/types/github"; + +export class UserFetchError extends Error { + readonly username: string; + readonly causeError: unknown; + + constructor(username: string, causeError: unknown) { + super(`Failed to fetch GitHub data for ${username}`); + this.name = "UserFetchError"; + this.username = username; + this.causeError = causeError; + } +} + +export type UserProfileResponse = { + user: UserResult; + location: string | null; +}; + +export async function getUserProfile( + username: string, + selectedLanguages: string[] = [], +): Promise { + const normalizedUsername = username.trim(); + if (!normalizedUsername) { + throw new Error("Username is required"); + } + + let data: GitHubUserData; + try { + const { data: userData } = await getUserData(normalizedUsername, { + cacheInRedis: true, + withMetrics: true, + }); + data = userData; + } catch (error: unknown) { + throw new UserFetchError(normalizedUsername, error); + } + + const score = calculateUserScore( + { + ...data, + selectedLanguages, + }, + normalizedUsername, + ); + + const user: UserResult = { + username: data.login, + name: data.name, + avatarUrl: data.avatarUrl, + repoScore: Math.round(score.repoScore), + prScore: Math.round(score.prScore), + contributionScore: Math.round(score.contributionScore), + finalScore: Math.round(score.finalScore), + normalizedRepoScore: Math.round(score.normalizedRepoScore), + normalizedPRScore: Math.round(score.normalizedPRScore), + normalizedContributionScore: Math.round(score.normalizedContributionScore), + normalizedFinalScore: Math.round(score.normalizedFinalScore), + topRepos: score.topRepos, + topPullRequests: score.topPullRequests, + topCommunityContributions: score.topCommunityContributions, + languageScores: score.languageScores, + signals: score.signals, + explanations: score.explanations, + scoreVersion: process.env.DEVIMPACT_VERSION || undefined, + }; + + // Fire-and-forget: detect country & upsert into DB if configured + const country = detectCountry(data.location); + if (country && process.env.DATABASE_URL?.trim()) { + const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); + + try { + const db = getDatabaseStore(); + db.upsertUser({ + username: data.login, + name: data.name, + avatarUrl: data.avatarUrl, + location: data.location, + country, + rawData: data, + scores: score, + repoScore: Math.round(score.repoScore), + prScore: Math.round(score.prScore), + contributionScore: Math.round(score.contributionScore), + finalScore: Math.round(score.finalScore), + staleDays, + }) + .then(() => { + const cacheConfig = getCacheConfigFromEnv(); + const cacheStore = createCacheStore(cacheConfig); + if (cacheStore.enabled && cacheStore.del) { + const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; + cacheStore.del(key).catch(() => {}); + } + }) + .catch((err: unknown) => { + console.warn("Failed to upsert user from user profile:", err); + }); + } catch { + // Ignore in environments without DB + } + } + + return { + user, + location: data.location ?? null, + }; +} diff --git a/locales/ar.json b/locales/ar.json index c1182eb..911bfc8 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -250,5 +250,28 @@ "leaderboard.noCountriesSearch": "لا توجد دول تطابق بحثك.", "leaderboard.noDevelopersFor": "لم يتم حساب قائمة المتصدرين في هذه الدولة بعد.", "leaderboard.error.retry": "حاول مرة أخرى", - "leaderboard.viewCountry": "عرض لوحة المتصدرين" + "leaderboard.viewCountry": "عرض لوحة المتصدرين", + "profile.backHome": "العودة إلى الرئيسية", + "profile.backToLeaderboard": "تصفح لوحات الصدارة", + "profile.compareWith": "مقارنة مع مطور آخر", + "profile.copied": "تم نسخ الرابط!", + "profile.copyLink": "مشاركة الملف الشخصي", + "profile.directStats": "تحليلات التأثير الفردي", + "profile.header.eyebrow": "الملف الشخصي للمطور", + "profile.loading": "جاري تحميل الملف الشخصي للمطور...", + "profile.location": "الموقع", + "profile.metaDescription": "استكشف درجة تأثير المطور مفتوح المصدر لـ {name} (@{username})، وأفضل المستودعات، وطلبات السحب المدمجة، ومساهمات المجتمع على DevImpact.", + "profile.metaTitle": "{name} (@{username}) - تأثير وإحصائيات المطور على GitHub", + "profile.methodologyCta": "تعرف على كيفية حساب درجة هذا المطور", + "profile.notFound.description": "لم نتمكن من العثور على بيانات GitHub للمستخدم @{username}. يرجى التحقق من اسم المستخدم والمحاولة مرة أخرى.", + "profile.notFound.title": "لم يتم العثور على المطور", + "profile.openInGithub": "فتح على GitHub", + "profile.scoreDistribution": "توزيع أوزان الدرجات", + "profile.scoreOverview": "نظرة عامة على درجة التأثير", + "profile.signalShare": "{pct}% من الإشارة النهائية", + "profile.title": "تأثير وإحصائيات المطور", + "profile.topWork": "أبرز الأعمال والمساهمات", + "profile.transparencyTitle": "إشارات شفافية التقييم", + "profile.viewFullStats": "عرض الملف الشخصي للمطور", + "profile.viewOnGithub": "عرض الملف الشخصي على GitHub" } diff --git a/locales/en.json b/locales/en.json index af52421..1c68bbd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -250,5 +250,28 @@ "leaderboard.noCountriesSearch": "No countries match your search.", "leaderboard.noDevelopersFor": "No leaderboard has been calculated for this country yet.", "leaderboard.error.retry": "Try again", - "leaderboard.viewCountry": "View leaderboard" + "leaderboard.viewCountry": "View leaderboard", + "profile.backHome": "Back to Home", + "profile.backToLeaderboard": "Browse Leaderboards", + "profile.compareWith": "Compare with another developer", + "profile.copied": "Link Copied!", + "profile.copyLink": "Share Profile", + "profile.directStats": "Individual Impact Analytics", + "profile.header.eyebrow": "Developer Profile", + "profile.loading": "Loading developer profile...", + "profile.location": "Location", + "profile.metaDescription": "Explore {name}'s (@{username}) open-source developer impact score, top repositories, merged pull requests, and community contributions on DevImpact.", + "profile.metaTitle": "{name} (@{username}) - GitHub Impact & Developer Stats", + "profile.methodologyCta": "Learn how this developer score was calculated", + "profile.notFound.description": "We couldn't find GitHub data for @{username}. Please check the username and try again.", + "profile.notFound.title": "Developer Not Found", + "profile.openInGithub": "Open on GitHub", + "profile.scoreDistribution": "Score Weight Distribution", + "profile.scoreOverview": "Impact Score Overview", + "profile.signalShare": "{pct}% of final signal", + "profile.title": "Developer Impact & Statistics", + "profile.topWork": "Top Work & Highlights", + "profile.transparencyTitle": "Scoring Transparency Signals", + "profile.viewFullStats": "View developer profile", + "profile.viewOnGithub": "View GitHub Profile" } diff --git a/scripts/init-db.ts b/scripts/init-db.ts index 07e22ca..d05cb71 100644 --- a/scripts/init-db.ts +++ b/scripts/init-db.ts @@ -8,6 +8,7 @@ * - PostgreSQL must be running (e.g. `docker compose up -d postgres`) * - DATABASE_URL must be set in .env or environment */ +import "dotenv/config"; import { getDatabaseStore } from "../lib/db-store"; async function main() { diff --git a/test/api/user.route.test.ts b/test/api/user.route.test.ts new file mode 100644 index 0000000..f2f1adb --- /dev/null +++ b/test/api/user.route.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { GitHubApiError } from "@/lib/github-graphql-client"; + +const mocks = vi.hoisted(() => ({ + getUserData: vi.fn(), + calculateUserScore: vi.fn(), +})); + +vi.mock("@/lib/github", () => ({ + getUserData: mocks.getUserData, +})); + +vi.mock("@/lib/score", () => ({ + calculateUserScore: mocks.calculateUserScore, +})); + +import { GET } from "@/app/api/user/[username]/route"; +import { getUserProfile } from "@/lib/user"; + +function makeUser(login: string, name: string) { + return { + login, + name, + avatarUrl: `https://example.com/${login}.png`, + location: "Stockholm, Sweden", + repos: [], + pullRequests: [], + contributions: { + totalCommitContributions: 0, + totalPullRequestContributions: 0, + totalIssueContributions: 0, + }, + issues: [], + discussions: [], + }; +} + +function makeScore(finalScore: number) { + return { + repoScore: 15, + prScore: 25, + contributionScore: 5, + finalScore, + normalizedRepoScore: 35, + normalizedPRScore: 45, + normalizedContributionScore: 10, + normalizedFinalScore: 40, + topRepos: [ + { + name: "test-repo", + url: "https://github.com/torvalds/test-repo", + stars: 100, + forks: 20, + watchers: 100, + score: 15, + }, + ], + topPullRequests: [ + { + repo: "torvalds/linux", + stars: 500, + score: 25, + title: "Kernel patch", + url: "https://github.com/torvalds/linux/pull/1", + additions: 100, + deletions: 20, + }, + ], + topCommunityContributions: [ + { + type: "issue" as const, + title: "Test issue", + url: "https://github.com/torvalds/linux/issues/2", + repo: "torvalds/linux", + stars: 50, + comments: 10, + score: 5, + }, + ], + signals: { + reposAnalyzed: 10, + pullRequestsAnalyzed: 5, + mergedExternalPRs: 3, + ownRepoPRsIgnored: 2, + unmergedPRsIgnored: 0, + uniqueExternalPRRepos: 2, + issuesAnalyzed: 4, + externalIssuesCounted: 2, + discussionsAnalyzed: 1, + externalDiscussionsCounted: 1, + }, + explanations: { + repo: [], + pr: [], + contribution: [], + overall: [], + }, + scoreVersion: "1.0", + }; +} + +describe("GET /api/user/[username]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns user profile result for valid username", async () => { + const rawUser = makeUser("torvalds", "Linus Torvalds"); + mocks.getUserData.mockResolvedValueOnce({ + data: rawUser, + metrics: { duration: 100, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValueOnce(makeScore(40)); + + const request = new Request("http://localhost/api/user/torvalds"); + const response = await GET(request, { + params: Promise.resolve({ username: "torvalds" }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + success: boolean; + user: { + username: string; + name: string; + finalScore: number; + repoScore: number; + prScore: number; + contributionScore: number; + }; + location: string; + }; + + expect(body.success).toBe(true); + expect(body.user.username).toBe("torvalds"); + expect(body.user.name).toBe("Linus Torvalds"); + expect(body.user.finalScore).toBe(40); + expect(body.location).toBe("Stockholm, Sweden"); + }); + + test("returns 404 when user is not found on GitHub", async () => { + mocks.getUserData.mockRejectedValueOnce( + new GitHubApiError({ + message: "Could not resolve to a User with the login of 'missing'.", + kind: "NOT_FOUND", + status: 200, + rateLimit: {}, + }), + ); + + const request = new Request("http://localhost/api/user/missing"); + const response = await GET(request, { + params: Promise.resolve({ username: "missing" }), + }); + + expect(response.status).toBe(404); + const body = (await response.json()) as { + success: boolean; + error: string; + errorDetails: { code: string }; + }; + + expect(body.success).toBe(false); + expect(body.errorDetails.code).toBe("GITHUB_NOT_FOUND"); + }); + + test("returns 429 when GitHub rate limit is hit", async () => { + mocks.getUserData.mockRejectedValueOnce( + new GitHubApiError({ + message: "API rate limit exceeded", + kind: "PRIMARY_RATE_LIMIT", + status: 403, + rateLimit: { remaining: 0, retryAfterSeconds: 60 }, + }), + ); + + const request = new Request("http://localhost/api/user/ratelimited"); + const response = await GET(request, { + params: Promise.resolve({ username: "ratelimited" }), + }); + + expect(response.status).toBe(429); + }); + + test("returns 400 when username is empty", async () => { + const request = new Request("http://localhost/api/user/"); + const response = await GET(request, { + params: Promise.resolve({ username: " " }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { success: boolean; error: string }; + expect(body.success).toBe(false); + }); + + test("getUserProfile helper calculates score and extracts signals correctly", async () => { + const rawUser = makeUser("octocat", "The Octocat"); + mocks.getUserData.mockResolvedValueOnce({ + data: rawUser, + metrics: { duration: 50, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValueOnce(makeScore(55)); + + const result = await getUserProfile("octocat"); + + expect(result.user.username).toBe("octocat"); + expect(result.user.finalScore).toBe(55); + expect(result.location).toBe("Stockholm, Sweden"); + expect(result.user.topRepos.length).toBe(1); + expect(result.user.signals?.reposAnalyzed).toBe(10); + }); +}); From 07c7bda28eaea00f02226854f8310ead7f272021 Mon Sep 17 00:00:00 2001 From: ws-rush Date: Sat, 29 Aug 2026 10:31:16 +0300 Subject: [PATCH 2/4] fix(profile): preserve country context and improve back navigation - Pass ?country= parameter when navigating from country leaderboards - Update back button to return to specific country leaderboard with fallback - Add breadcrumb navigation (Home / Leaderboards / Country / User) - Add Arabic and English localization for back navigation and breadcrumbs --- .../[country]/country-leaderboard-client.tsx | 3 + app/leaderboard/[country]/page.tsx | 6 +- app/user/[username]/page.tsx | 81 +++++++++++----- components/leaderboard-table.tsx | 74 ++++++++------ components/user-profile-client.tsx | 96 ++++++++++++++++--- locales/ar.json | 3 + locales/en.json | 3 + 7 files changed, 202 insertions(+), 64 deletions(-) diff --git a/app/leaderboard/[country]/country-leaderboard-client.tsx b/app/leaderboard/[country]/country-leaderboard-client.tsx index fedc345..82d4bdb 100644 --- a/app/leaderboard/[country]/country-leaderboard-client.tsx +++ b/app/leaderboard/[country]/country-leaderboard-client.tsx @@ -12,12 +12,14 @@ import type { LeaderboardResult } from "@/lib/leaderboard"; type Props = { countryTitle: string; + countrySlug?: string; initialLeaderboard: LeaderboardResult; initialError?: string | null; }; export function CountryLeaderboardClient({ countryTitle, + countrySlug, initialLeaderboard, initialError = null, }: Props) { @@ -82,6 +84,7 @@ export function CountryLeaderboardClient({ users={scored} failedUsers={errors} title={title} + countrySlug={countrySlug} totalFromSource={totalFromSource} usersProcessed={scored.length} /> diff --git a/app/leaderboard/[country]/page.tsx b/app/leaderboard/[country]/page.tsx index 7b7f670..a9f60ea 100644 --- a/app/leaderboard/[country]/page.tsx +++ b/app/leaderboard/[country]/page.tsx @@ -168,7 +168,11 @@ export default async function CountryLeaderboardPage({ params }: Props) { : [webPageSchema, breadcrumbSchema] } /> - + ); } diff --git a/app/user/[username]/page.tsx b/app/user/[username]/page.tsx index 7e1b69a..ae2303c 100644 --- a/app/user/[username]/page.tsx +++ b/app/user/[username]/page.tsx @@ -7,8 +7,19 @@ import { AppFooter } from "@/components/app-footer"; import { getUserProfile } from "@/lib/user"; import { toAbsoluteUrl } from "@/lib/seo"; +import countriesData from "@/data/countries.json"; +import { detectCountry } from "@/lib/location-detector"; + +type CountryInfo = { + slug: string; + title: string; +}; + +const countries = countriesData as CountryInfo[]; + type Props = { params: Promise<{ username: string }>; + searchParams?: Promise<{ country?: string }>; }; export async function generateMetadata({ params }: Props): Promise { @@ -74,11 +85,14 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function UserProfilePage({ params }: Props) { +export default async function UserProfilePage({ params, searchParams }: Props) { const { username } = await params; const cleanUsername = decodeURIComponent(username.trim()); const profileUrl = toAbsoluteUrl(`/user/${cleanUsername}`); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const countryParam = resolvedSearchParams?.country; + let profileData: Awaited> | null = null; let fetchErrorMessage: string | null = null; @@ -131,29 +145,52 @@ export default async function UserProfilePage({ params }: Props) { }, }; + const detectedSlug = (countryParam || detectCountry(location))?.trim().toLowerCase(); + const countryInfo = detectedSlug + ? countries.find((c) => c.slug.toLowerCase() === detectedSlug) + : null; + + const breadcrumbElements = [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: toAbsoluteUrl("/"), + }, + { + "@type": "ListItem", + position: 2, + name: "Leaderboards", + item: toAbsoluteUrl("/leaderboard"), + }, + ]; + + if (countryInfo) { + breadcrumbElements.push({ + "@type": "ListItem", + position: 3, + name: countryInfo.title, + item: toAbsoluteUrl(`/leaderboard/${countryInfo.slug}`), + }); + breadcrumbElements.push({ + "@type": "ListItem", + position: 4, + name: displayName, + item: profileUrl, + }); + } else { + breadcrumbElements.push({ + "@type": "ListItem", + position: 3, + name: displayName, + item: profileUrl, + }); + } + const breadcrumbSchema = { "@context": "https://schema.org", "@type": "BreadcrumbList", - itemListElement: [ - { - "@type": "ListItem", - position: 1, - name: "Home", - item: toAbsoluteUrl("/"), - }, - { - "@type": "ListItem", - position: 2, - name: "Leaderboards", - item: toAbsoluteUrl("/leaderboard"), - }, - { - "@type": "ListItem", - position: 3, - name: displayName, - item: profileUrl, - }, - ], + itemListElement: breadcrumbElements, }; return ( @@ -163,7 +200,7 @@ export default async function UserProfilePage({ params }: Props) { - +
diff --git a/components/leaderboard-table.tsx b/components/leaderboard-table.tsx index 631af93..50cf2ba 100644 --- a/components/leaderboard-table.tsx +++ b/components/leaderboard-table.tsx @@ -26,6 +26,7 @@ type Props = { users: LeaderboardEntry[]; failedUsers: string[]; title: string; + countrySlug?: string; totalFromSource: number; usersProcessed: number; }; @@ -38,6 +39,7 @@ export function LeaderboardTable({ users, failedUsers, title, + countrySlug, totalFromSource, usersProcessed, }: Props) { @@ -147,38 +149,50 @@ export function LeaderboardTable({ -
- - - -
-
- - {user.name || user.username} + {(() => { + const profileUrl = ( + countrySlug + ? `/user/${user.username}?country=${encodeURIComponent(countrySlug)}` + : `/user/${user.username}` + ) as Route; + + return ( +
+ + - - - +
+
+ + {user.name || user.username} + + + + +
+

+ @{user.username} +

+
-

@{user.username}

-
-
+ ); + })()} {user.finalScore} diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx index bf8950e..0174c9a 100644 --- a/components/user-profile-client.tsx +++ b/components/user-profile-client.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import type { Route } from "next"; import { ArrowLeft, @@ -25,11 +26,20 @@ import { Progress } from "@/components/ui/progress"; import { useTranslation } from "@/components/language-provider"; import { getCountryCode } from "@/lib/country-flags"; import { detectCountry } from "@/lib/location-detector"; +import countriesData from "@/data/countries.json"; import type { UserResult } from "@/types/user-result"; +type CountryInfo = { + slug: string; + title: string; +}; + +const countries = countriesData as CountryInfo[]; + type Props = { user: UserResult; location?: string | null; + countryParam?: string | null; }; type LanguageEntry = { @@ -95,17 +105,35 @@ function LanguageBreakdown({ topLanguages }: { topLanguages?: LanguageEntry[] }) ); } -export function UserProfileClient({ user, location }: Props) { +export function UserProfileClient({ user, location, countryParam }: Props) { const { t } = useTranslation(); + const searchParams = useSearchParams(); const [copied, setCopied] = useState(false); const displayName = user.name?.trim() || user.username; const githubUrl = `https://github.com/${user.username}`; const compareUrl = `/?user1=${encodeURIComponent(user.username)}`; - // Location & Country flag detection + // Location & Country detection const detectedSlug = detectCountry(location ?? null); - const flagCode = detectedSlug ? getCountryCode(detectedSlug) : null; + const rawCountry = (countryParam || searchParams.get("country") || detectedSlug || "") + .trim() + .toLowerCase(); + const activeCountrySlug = rawCountry ? rawCountry.replace(/[^a-z0-9_-]/g, "") : null; + + const activeCountryInfo = activeCountrySlug + ? (countries.find((c) => c.slug.toLowerCase() === activeCountrySlug) ?? { + slug: activeCountrySlug, + title: activeCountrySlug + .split("_") + .filter(Boolean) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(" "), + }) + : null; + + const flagSlug = activeCountryInfo?.slug || detectedSlug; + const flagCode = flagSlug ? getCountryCode(flagSlug) : null; const handleCopyLink = async () => { try { @@ -170,14 +198,60 @@ export function UserProfileClient({ user, location }: Props) { return (
- {/* ── Back Navigation ────────────────────────────────────────── */} -
- - - + {/* ── Breadcrumb & Back Navigation ────────────────────────────── */} +
+
+ {activeCountryInfo ? ( + + + + ) : ( + + + + )} +
+ + {/* Breadcrumbs */} +
{/* ── Header Profile Hero Section ────────────────────────────── */} diff --git a/locales/ar.json b/locales/ar.json index 911bfc8..ea82b76 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -252,7 +252,10 @@ "leaderboard.error.retry": "حاول مرة أخرى", "leaderboard.viewCountry": "عرض لوحة المتصدرين", "profile.backHome": "العودة إلى الرئيسية", + "profile.backToCountry": "العودة إلى لوحة صدارة {country}", "profile.backToLeaderboard": "تصفح لوحات الصدارة", + "profile.breadcrumbs.home": "الرئيسية", + "profile.breadcrumbs.leaderboards": "لوحات الصدارة", "profile.compareWith": "مقارنة مع مطور آخر", "profile.copied": "تم نسخ الرابط!", "profile.copyLink": "مشاركة الملف الشخصي", diff --git a/locales/en.json b/locales/en.json index 1c68bbd..1a6da07 100644 --- a/locales/en.json +++ b/locales/en.json @@ -252,7 +252,10 @@ "leaderboard.error.retry": "Try again", "leaderboard.viewCountry": "View leaderboard", "profile.backHome": "Back to Home", + "profile.backToCountry": "Back to {country} leaderboard", "profile.backToLeaderboard": "Browse Leaderboards", + "profile.breadcrumbs.home": "Home", + "profile.breadcrumbs.leaderboards": "Leaderboards", "profile.compareWith": "Compare with another developer", "profile.copied": "Link Copied!", "profile.copyLink": "Share Profile", From 71dcff53d0df1e0d6ff3bc257ee2beca88ab322e Mon Sep 17 00:00:00 2001 From: ws-rush Date: Sat, 29 Aug 2026 10:33:23 +0300 Subject: [PATCH 3/4] fix(profile): make compare and share buttons side by side on mobile --- components/user-profile-client.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx index 0174c9a..0536858 100644 --- a/components/user-profile-client.tsx +++ b/components/user-profile-client.tsx @@ -312,11 +312,11 @@ export function UserProfileClient({ user, location, countryParam }: Props) {
{/* Actions */} -
- - @@ -324,18 +324,18 @@ export function UserProfileClient({ user, location, countryParam }: Props) { variant="secondary" size="md" onClick={handleCopyLink} - className="flex items-center gap-1.5" + className="flex w-full items-center justify-center gap-1.5 px-3 text-xs sm:px-4 sm:text-sm" aria-label={t("profile.copyLink")} > {copied ? ( <> - - {t("profile.copied")} + + {t("profile.copied")} ) : ( <> - - {t("profile.copyLink")} + + {t("profile.copyLink")} )} From e181e22c34f419c076aea5b99237d29317249a09 Mon Sep 17 00:00:00 2001 From: ws-rush Date: Sat, 29 Aug 2026 10:36:45 +0300 Subject: [PATCH 4/4] fix(profile): use concise compare and share button labels on mobile --- components/user-profile-client.tsx | 13 ++++++++++--- locales/ar.json | 3 +++ locales/en.json | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/components/user-profile-client.tsx b/components/user-profile-client.tsx index 0536858..797e0e5 100644 --- a/components/user-profile-client.tsx +++ b/components/user-profile-client.tsx @@ -316,7 +316,8 @@ export function UserProfileClient({ user, location, countryParam }: Props) { @@ -330,12 +331,18 @@ export function UserProfileClient({ user, location, countryParam }: Props) { {copied ? ( <> - {t("profile.copied")} + + {t("profile.copiedShort")} + + + {t("profile.copied")} + ) : ( <> - {t("profile.copyLink")} + {t("profile.shareShort")} + {t("profile.copyLink")} )} diff --git a/locales/ar.json b/locales/ar.json index ea82b76..a2cd485 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -256,9 +256,12 @@ "profile.backToLeaderboard": "تصفح لوحات الصدارة", "profile.breadcrumbs.home": "الرئيسية", "profile.breadcrumbs.leaderboards": "لوحات الصدارة", + "profile.compareShort": "مقارنة", "profile.compareWith": "مقارنة مع مطور آخر", "profile.copied": "تم نسخ الرابط!", + "profile.copiedShort": "تم النسخ!", "profile.copyLink": "مشاركة الملف الشخصي", + "profile.shareShort": "مشاركة", "profile.directStats": "تحليلات التأثير الفردي", "profile.header.eyebrow": "الملف الشخصي للمطور", "profile.loading": "جاري تحميل الملف الشخصي للمطور...", diff --git a/locales/en.json b/locales/en.json index 1a6da07..c72f96d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -256,9 +256,12 @@ "profile.backToLeaderboard": "Browse Leaderboards", "profile.breadcrumbs.home": "Home", "profile.breadcrumbs.leaderboards": "Leaderboards", + "profile.compareShort": "Compare", "profile.compareWith": "Compare with another developer", "profile.copied": "Link Copied!", + "profile.copiedShort": "Copied!", "profile.copyLink": "Share Profile", + "profile.shareShort": "Share", "profile.directStats": "Individual Impact Analytics", "profile.header.eyebrow": "Developer Profile", "profile.loading": "Loading developer profile...",