diff --git a/app/components/_layouts/navbar/profile-menu.test.tsx b/app/components/_layouts/navbar/profile-menu.test.tsx new file mode 100644 index 00000000..1e9fb810 --- /dev/null +++ b/app/components/_layouts/navbar/profile-menu.test.tsx @@ -0,0 +1,55 @@ +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import type { User } from "~/lib/models/user.server"; +import { renderWithRouter } from "~/test/render-with-router"; +import ProfileMenu from "./profile-menu"; + +describe("ProfileMenu", () => { + const baseUser: User = { + id: 1, + name: "Ícaro Harry", + email: "icaro@example.com", + github_id: "123", + is_pro: true, + github_user: "icaroharry", + settings: null, + avatar: { + avatar_url: "https://example.com/avatar.jpg", + name: "Ícaro Harry", + badge: null, + github_user: "icaroharry", + }, + }; + + it("shows a Meu Perfil link when the user has a github username", async () => { + const user = userEvent.setup(); + + renderWithRouter(() => ); + + await user.click(screen.getByRole("button")); + + expect( + screen.getByRole("menuitem", { name: "Meu Perfil" }), + ).toHaveAttribute("href", "/perfil/icaroharry"); + }); + + it("hides the Meu Perfil link when the user has no github username", async () => { + const user = userEvent.setup(); + const userWithoutGithub = { + ...baseUser, + github_user: undefined, + avatar: { + ...baseUser.avatar, + github_user: undefined, + }, + }; + + renderWithRouter(() => ); + + await user.click(screen.getByRole("button")); + + expect( + screen.queryByRole("menuitem", { name: "Meu Perfil" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/app/components/_layouts/navbar/profile-menu.tsx b/app/components/_layouts/navbar/profile-menu.tsx index 76dd635e..b979afce 100644 --- a/app/components/_layouts/navbar/profile-menu.tsx +++ b/app/components/_layouts/navbar/profile-menu.tsx @@ -7,6 +7,8 @@ import classNames from "~/lib/utils/class-names"; import UserAvatar from "~/components/ui/user-avatar"; export default function ProfileMenu({ user }: { user: User }) { + const githubUser = user.github_user ?? user.avatar?.github_user; + return (
@@ -65,6 +67,21 @@ export default function ProfileMenu({ user }: { user: User }) { )} + {githubUser && ( + + {({ active }) => ( + + Meu Perfil + + )} + + )} {({ active }) => (
diff --git a/app/components/ui/cards/challenge-card.test.tsx b/app/components/ui/cards/challenge-card.test.tsx index 55963fd1..b2ff82dc 100644 --- a/app/components/ui/cards/challenge-card.test.tsx +++ b/app/components/ui/cards/challenge-card.test.tsx @@ -7,6 +7,12 @@ vi.mock("~/components/ui/tooltip", () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, })); +vi.mock("~/components/ui/user-avatar-hover-card", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + const mockChallenge = { id: 1, name: "Landing Page com Tailwind", @@ -91,4 +97,17 @@ describe("ChallengeCard", () => { const link = screen.getByRole("link"); expect(link.className).toContain("cursor-not-allowed"); }); + + it("does not render avatar hover cards by default", () => { + renderWithRouter(() => ); + expect(screen.queryByTestId("avatar-hover-card")).not.toBeInTheDocument(); + }); + + it("renders avatar hover cards when enabled", () => { + renderWithRouter(() => ( + + )); + + expect(screen.getAllByTestId("avatar-hover-card")).toHaveLength(2); + }); }); diff --git a/app/components/ui/cards/challenge-card.tsx b/app/components/ui/cards/challenge-card.tsx index e9edede7..8b087be2 100644 --- a/app/components/ui/cards/challenge-card.tsx +++ b/app/components/ui/cards/challenge-card.tsx @@ -3,6 +3,7 @@ import type { ChallengeCard as ChallengeCardType } from "~/lib/models/challenge. import classNames from "~/lib/utils/class-names"; import UserAvatar from "../user-avatar"; +import UserAvatarHoverCard from "~/components/ui/user-avatar-hover-card"; import CardItemLevel from "~/components/ui/cards/card-item-level"; import CardItemMainTechnology from "~/components/ui/cards/card-item-main-technology"; import { ArrowRight } from "lucide-react"; @@ -13,10 +14,12 @@ export default function ChallengeCard({ challenge, className = "", openInNewTab, + showAvatarHoverCard = false, }: { challenge: ChallengeCardType; openInNewTab?: boolean; className?: string; + showAvatarHoverCard?: boolean; }) { return (
- {challenge?.avatars?.map((avatar, index) => ( - - ))} + {challenge?.avatars?.map((avatar, index) => + showAvatarHoverCard ? ( + + + + ) : ( + + ), + )} {challenge.enrolled_users_count > 5 && (
+{challenge.enrolled_users_count - 5} diff --git a/app/components/ui/user-avatar-hover-card/index.tsx b/app/components/ui/user-avatar-hover-card/index.tsx new file mode 100644 index 00000000..c7d4b81a --- /dev/null +++ b/app/components/ui/user-avatar-hover-card/index.tsx @@ -0,0 +1,166 @@ +import { Link, useFetcher } from "react-router"; +import { useRef, useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { BsFillHeartFill, BsTools } from "react-icons/bs"; +import { FaMedal } from "react-icons/fa"; +import type { UserAvatar, UserProfile } from "~/lib/models/user.server"; +import { useMediaQuery } from "~/lib/hooks/use-media-query"; +import { formatName } from "~/lib/utils/format-name"; +import ProBadge from "~/components/ui/pro-badge"; + +export default function UserAvatarHoverCard({ + avatar, + children, + profileTarget = "_self", +}: { + avatar: UserAvatar; + children: React.ReactNode; + profileTarget?: "_self" | "_blank"; +}) { + const isMobile = useMediaQuery("(max-width: 768px)"); + const fetcher = useFetcher(); + const [isOpen, setIsOpen] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const triggerRef = useRef(null); + const timeoutRef = useRef | null>(null); + + const openCard = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setPosition({ + top: rect.top + window.scrollY - 8, + left: rect.left + window.scrollX + rect.width / 2, + }); + } + setIsOpen(true); + if (!hasLoaded && avatar.github_user && fetcher.state === "idle") { + fetcher.load(`/user-profile?github_user=${avatar.github_user}`); + setHasLoaded(true); + } + }, 300); + }, [hasLoaded, avatar.github_user, fetcher]); + + const closeCard = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, 200); + }, []); + + const keepOpen = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + if (isMobile || !avatar.github_user) { + return <>{children}; + } + + const profile = fetcher.data; + + return ( + <> + + {children} + + {isOpen && + typeof document !== "undefined" && + createPortal( +
+
+ e.stopPropagation()} + > +
+ {avatar.name} +
+
+ + {formatName(avatar.name)} + + {avatar.badge === "pro" && } +
+ + @{avatar.github_user} + +
+
+ + {fetcher.state === "loading" && ( +
+
+
+
+
+ )} + + {profile && ( +
+ + + {profile.stats.completed_challenge_count} + + + + {profile.stats.received_reaction_count} + + + + {profile.stats.points} + +
+ )} + + + Ver perfil completo + + +
+
, + document.body, + )} + + ); +} diff --git a/app/lib/models/user.server.ts b/app/lib/models/user.server.ts index c43df1f4..4b0c9eaf 100644 --- a/app/lib/models/user.server.ts +++ b/app/lib/models/user.server.ts @@ -53,6 +53,59 @@ export type UserAvatar = { github_user?: string; }; +export type UserProfile = { + user: { + name: string; + github_user: string; + linkedin_user: string | null; + avatar: UserAvatar; + created_at: string; + }; + stats: { + points: number; + completed_challenge_count: number; + received_reaction_count: number; + }; + completed_challenges: ProfileChallengeSubmission[]; + certificates: ProfileCertificate[]; +}; + +export type ProfileChallengeSubmission = { + id: number; + submission_image_url: string; + submission_url: string; + submitted_at: string; + challenge: { + name: string; + slug: string; + image_url: string; + }; +}; + +export type ProfileCertificate = { + id: string; + metadata: { + certifiable_source_name: string; + certifiable_slug: string; + [key: string]: unknown; + }; + status: string; + created_at: string; +}; + +export async function getUserProfile( + githubUser: string, +): Promise { + const axios = await createAxios(); + return axios + .get(`/users/${githubUser}/profile`) + .then((res) => res.data.data) + .catch((e) => { + if (e.response?.status === 404) return null; + throw e; + }); +} + export async function impersonate( request: any, userId: string, diff --git a/app/lib/services/auth.server.ts b/app/lib/services/auth.server.ts index ef63f4a9..0d04b4ca 100644 --- a/app/lib/services/auth.server.ts +++ b/app/lib/services/auth.server.ts @@ -107,10 +107,15 @@ export async function logout({ const user = session.get("user"); - if (!user?.token) return redirect(redirectTo); - const axios = await createAxios(request); + if (user?.token) { + const axios = await createAxios(request); - await axios.post("/logout"); + try { + await axios.post("/logout"); + } catch { + // Even if the API token is already invalid, we still need to clear the app session. + } + } return redirect(redirectTo, { headers: { @@ -134,9 +139,13 @@ export async function logoutWithRedirectAfterLogin({ const user = session.get("user"); - if (!user?.token) return redirect(`/login?redirectTo=${redirectTo}`); - - await axios.post("/logout"); + if (user?.token) { + try { + await axios.post("/logout"); + } catch { + // We still want to drop the local session and continue the re-login flow. + } + } return redirect(`/login?redirectTo=${redirectTo}`, { headers: { diff --git a/app/lib/services/github-auth.server.ts b/app/lib/services/github-auth.server.ts index 74df69fd..e452a00c 100644 --- a/app/lib/services/github-auth.server.ts +++ b/app/lib/services/github-auth.server.ts @@ -1,5 +1,6 @@ import { Authenticator } from "remix-auth"; import { GitHubStrategy } from "remix-auth-github"; +import { isAxiosError } from "axios"; import { environment } from "~/lib/models/environment"; import { createAxios } from "~/lib/services/axios.server"; @@ -15,13 +16,28 @@ const gitHubStrategy = new GitHubStrategy( async (params) => { const axios = await createAxios(); - const res = await axios.post("/github-login", { - github_token: params.tokens.accessToken(), - }); - const token = res.data.token; + try { + const res = await axios.post("/github-login", { + github_token: params.tokens.accessToken(), + }); + const token = res.data.token; - // Vamos enviar o token e o is_new_signup value para o cliente - return { token: token, is_new_signup: res.data.is_new_signup }; + // Vamos enviar o token e o is_new_signup value para o cliente + return { token: token, is_new_signup: res.data.is_new_signup }; + } catch (error) { + if (isAxiosError(error)) { + const status = error.response?.status ?? "unknown"; + const data = error.response?.data; + const backendMessage = + (typeof data?.error === "string" && data.error) || + (typeof data?.message === "string" && data.message) || + error.message; + + throw new Error(`GitHub login failed (${status}): ${backendMessage}`); + } + + throw error; + } }, ); diff --git a/app/routes/_api/user-profile/index.tsx b/app/routes/_api/user-profile/index.tsx new file mode 100644 index 00000000..ff314f94 --- /dev/null +++ b/app/routes/_api/user-profile/index.tsx @@ -0,0 +1,21 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { getUserProfile } from "~/lib/models/user.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const githubUser = url.searchParams.get("github_user"); + + if (!githubUser) { + return Response.json(null, { status: 400 }); + } + + const profile = await getUserProfile(githubUser); + + if (!profile) { + return Response.json(null, { status: 404 }); + } + + return Response.json(profile, { + headers: { "Cache-Control": "public, max-age=300" }, + }); +} diff --git a/app/routes/_landing-page/components/headline/avatars.test.tsx b/app/routes/_landing-page/components/headline/avatars.test.tsx new file mode 100644 index 00000000..0955d70c --- /dev/null +++ b/app/routes/_landing-page/components/headline/avatars.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import type { User } from "~/lib/models/user.server"; +import AvatarsSection from "./avatars"; + +vi.mock("~/components/ui/user-avatar-hover-card", () => ({ + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("~/components/ui/tooltip", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe("AvatarsSection", () => { + const user: User = { + id: 1, + name: "Maria", + email: "maria@example.com", + github_id: "123", + is_pro: false, + avatar: { + name: "Maria", + avatar_url: "https://example.com/maria.jpg", + badge: null, + github_user: "maria", + }, + settings: null, + github_user: "maria", + }; + + const avatars = [ + user.avatar, + { + name: "Bruno", + avatar_url: "https://example.com/bruno.jpg", + badge: "pro" as const, + github_user: "bruno", + }, + ]; + + it("renders avatar hover cards for the hero avatars", () => { + render(); + + expect(screen.getAllByTestId("avatar-hover-card")).toHaveLength(2); + expect(screen.getByText("6513 devs")).toBeInTheDocument(); + }); +}); diff --git a/app/routes/_landing-page/components/headline/avatars.tsx b/app/routes/_landing-page/components/headline/avatars.tsx index 7f663a2c..a0b1aa49 100644 --- a/app/routes/_landing-page/components/headline/avatars.tsx +++ b/app/routes/_landing-page/components/headline/avatars.tsx @@ -1,5 +1,6 @@ import { User, UserAvatar } from "~/lib/models/user.server"; import Avatar from "~/components/ui/user-avatar"; +import UserAvatarHoverCard from "~/components/ui/user-avatar-hover-card"; function AvatarsSection({ user, @@ -10,35 +11,32 @@ function AvatarsSection({ avatars: UserAvatar[]; userCount: number; }) { + const avatarClassName = "xl:w-9 xl:h-9 lg:h-[30px] lg:w-[30px] w-9 h-9"; + + function renderAvatar(avatar: UserAvatar, key: string) { + return ( + + + + ); + } + return (
- {user && ( - - )} + {user && renderAvatar(user.avatar, `current-user-${user.id}`)} {user ? avatars .filter((avatar) => avatar.avatar_url !== user.avatar.avatar_url) .slice(0, 8) - .map((avatar, index) => ( - - )) + .map((avatar, index) => renderAvatar(avatar, `avatar-${index}`)) : avatars .slice(0, 9) - .map((avatar, index) => ( - - ))} + .map((avatar, index) => renderAvatar(avatar, `avatar-${index}`))}

diff --git a/app/routes/_layout-app/_auth/login/index.test.ts b/app/routes/_layout-app/_auth/login/index.test.ts new file mode 100644 index 00000000..686d7e48 --- /dev/null +++ b/app/routes/_layout-app/_auth/login/index.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockLogin, mockUser } = vi.hoisted(() => ({ + mockLogin: vi.fn(), + mockUser: vi.fn(), +})); + +vi.mock("~/lib/services/auth.server", () => ({ + login: mockLogin, + user: mockUser, +})); + +import { loader } from "./index"; + +describe("login loader", () => { + beforeEach(() => { + mockLogin.mockReset(); + mockUser.mockReset(); + }); + + it("redirects authenticated users to home", async () => { + mockUser.mockResolvedValue({ + id: 1, + name: "Maria", + }); + + const response = await loader({ + request: new Request("http://localhost/login?redirectTo=/dashboard"), + } as any); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/"); + }); + + it("allows guests to open the login page even when redirectTo is present", async () => { + mockUser.mockResolvedValue(null); + + const response = await loader({ + request: new Request("http://localhost/login?redirectTo=/dashboard"), + } as any); + + expect(response).toEqual({ redirectTo: "/dashboard" }); + }); +}); diff --git a/app/routes/_layout-app/_auth/login/index.tsx b/app/routes/_layout-app/_auth/login/index.tsx index 9076cdfb..f3a1d000 100644 --- a/app/routes/_layout-app/_auth/login/index.tsx +++ b/app/routes/_layout-app/_auth/login/index.tsx @@ -10,7 +10,7 @@ import { } from "react-router"; import { useState } from "react"; import { useColorMode } from "~/lib/contexts/color-mode-context"; -import { login, sessionStorage } from "~/lib/services/auth.server"; +import { login, user as getUser } from "~/lib/services/auth.server"; import AuthCard from "../auth-card"; import LoadingButton from "~/components/features/form/loading-button"; import type { LoaderFunctionArgs } from "react-router"; @@ -59,15 +59,12 @@ export async function action({ request }: { request: Request }) { } export async function loader({ request }: LoaderFunctionArgs) { - // await authenticator.isAuthenticated(request, { - // successRedirect: "/", - // }); + const user = await getUser({ request }); - const session = await sessionStorage.getSession( - request.headers.get("Cookie"), - ); + if (user instanceof Response) { + return user; + } - const user = session.get("user"); if (user) { return redirect("/"); } diff --git a/app/routes/_layout-app/_mini-projetos/mini-projetos/featured-challenge-section.tsx b/app/routes/_layout-app/_mini-projetos/mini-projetos/featured-challenge-section.tsx index 807f1488..71269379 100644 --- a/app/routes/_layout-app/_mini-projetos/mini-projetos/featured-challenge-section.tsx +++ b/app/routes/_layout-app/_mini-projetos/mini-projetos/featured-challenge-section.tsx @@ -49,6 +49,7 @@ export default function FeaturedChallengeSection({

diff --git a/app/routes/_layout-app/_mini-projetos/mini-projetos/index.tsx b/app/routes/_layout-app/_mini-projetos/mini-projetos/index.tsx index f76394dd..abe4b1b8 100644 --- a/app/routes/_layout-app/_mini-projetos/mini-projetos/index.tsx +++ b/app/routes/_layout-app/_mini-projetos/mini-projetos/index.tsx @@ -118,7 +118,11 @@ export default function ChallengesIndex() {
{groupedChallenges.map((challenge: ChallengeCardType) => ( - + ))}
diff --git a/app/routes/_layout-app/_perfil/components/profile-certificates.tsx b/app/routes/_layout-app/_perfil/components/profile-certificates.tsx new file mode 100644 index 00000000..9c50346a --- /dev/null +++ b/app/routes/_layout-app/_perfil/components/profile-certificates.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router"; +import type { ProfileCertificate } from "~/lib/models/user.server"; +import { formatDate } from "~/lib/utils/format-date"; + +export default function ProfileCertificates({ + certificates, +}: { + certificates: ProfileCertificate[]; +}) { + return ( +
+

+ Certificados +

+ {certificates.length === 0 ? ( +

+ Nenhum certificado ainda. +

+ ) : ( +
+ {certificates.map((cert) => ( + +
📜
+
+

+ {cert.metadata?.certifiable_source_name ?? "Certificado"} +

+

+ {formatDate(cert.created_at)} +

+
+ + ))} +
+ )} +
+ ); +} diff --git a/app/routes/_layout-app/_perfil/components/profile-challenges.tsx b/app/routes/_layout-app/_perfil/components/profile-challenges.tsx new file mode 100644 index 00000000..e16945b4 --- /dev/null +++ b/app/routes/_layout-app/_perfil/components/profile-challenges.tsx @@ -0,0 +1,57 @@ +import { Link } from "react-router"; +import type { ProfileChallengeSubmission } from "~/lib/models/user.server"; + +export default function ProfileChallenges({ + challenges, + githubUser, +}: { + challenges: ProfileChallengeSubmission[]; + githubUser: string; +}) { + return ( +
+

+ Mini Projetos Concluídos +

+ {challenges.length === 0 ? ( +

+ Nenhum mini-projeto concluído ainda. +

+ ) : ( +
+ {challenges.map((submission) => ( + +
+ {submission.submission_image_url ? ( + {submission.challenge.name} + ) : ( +
+ {submission.challenge.name} +
+ )} +
+
+

+ {submission.challenge.name} +

+
+ + ))} +
+ )} +
+ ); +} diff --git a/app/routes/_layout-app/_perfil/components/profile-header.test.tsx b/app/routes/_layout-app/_perfil/components/profile-header.test.tsx new file mode 100644 index 00000000..e004b4c9 --- /dev/null +++ b/app/routes/_layout-app/_perfil/components/profile-header.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import ProfileHeader from "./profile-header"; + +// Mock Tooltip for UserAvatar +vi.mock("~/components/ui/tooltip", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const mockUser = { + name: "joão silva", + github_user: "joaosilva", + linkedin_user: "joao-silva", + avatar: { + avatar_url: "https://example.com/avatar.jpg", + name: "João Silva", + badge: "pro" as const, + github_user: "joaosilva", + }, + created_at: "2024-01-15T12:00:00Z", +}; + +describe("ProfileHeader", () => { + const renderHeader = (user = mockUser) => + render( + + + , + ); + + it("renders the user name formatted", () => { + renderHeader(); + expect(screen.getByText("João Silva")).toBeInTheDocument(); + }); + + it("renders PRO badge for pro users", () => { + renderHeader(); + expect(screen.getByText("PRO")).toBeInTheDocument(); + }); + + it("does not render PRO badge when badge is null", () => { + renderHeader({ + ...mockUser, + avatar: { ...mockUser.avatar, badge: null }, + }); + expect(screen.queryByText("PRO")).not.toBeInTheDocument(); + }); + + it("renders GitHub link", () => { + renderHeader(); + const link = screen.getByText("@joaosilva").closest("a"); + expect(link).toHaveAttribute("href", "https://github.com/joaosilva"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("renders LinkedIn link when present", () => { + renderHeader(); + const link = screen.getByText("LinkedIn").closest("a"); + expect(link).toHaveAttribute("href", "https://linkedin.com/in/joao-silva"); + }); + + it("hides LinkedIn link when null", () => { + renderHeader({ ...mockUser, linkedin_user: null }); + expect(screen.queryByText("LinkedIn")).not.toBeInTheDocument(); + }); + + it("renders member since date", () => { + renderHeader(); + expect(screen.getByText(/Membro desde/)).toBeInTheDocument(); + expect(screen.getByText(/janeiro/)).toBeInTheDocument(); + }); +}); diff --git a/app/routes/_layout-app/_perfil/components/profile-header.tsx b/app/routes/_layout-app/_perfil/components/profile-header.tsx new file mode 100644 index 00000000..be989e65 --- /dev/null +++ b/app/routes/_layout-app/_perfil/components/profile-header.tsx @@ -0,0 +1,55 @@ +import { FaGithub, FaLinkedin } from "react-icons/fa"; +import UserAvatar from "~/components/ui/user-avatar"; +import ProBadge from "~/components/ui/pro-badge"; +import { formatName } from "~/lib/utils/format-name"; +import { formatDate } from "~/lib/utils/format-date"; +import type { UserProfile } from "~/lib/models/user.server"; + +export default function ProfileHeader({ user }: { user: UserProfile["user"] }) { + return ( +
+ +
+
+

+ {formatName(user.name)} +

+ {user.avatar.badge === "pro" && } +
+ +
+ {user.github_user && ( + + + @{user.github_user} + + )} + {user.linkedin_user && ( + + + LinkedIn + + )} +
+ +

+ Membro desde {formatDate(user.created_at)} +

+
+
+ ); +} diff --git a/app/routes/_layout-app/_perfil/components/profile-stats.test.tsx b/app/routes/_layout-app/_perfil/components/profile-stats.test.tsx new file mode 100644 index 00000000..c7b85661 --- /dev/null +++ b/app/routes/_layout-app/_perfil/components/profile-stats.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import ProfileStats from "./profile-stats"; + +describe("ProfileStats", () => { + const mockStats = { + points: 250, + completed_challenge_count: 12, + received_reaction_count: 45, + }; + + it("renders challenge count", () => { + render(); + expect(screen.getByText("12")).toBeInTheDocument(); + }); + + it("renders reaction count", () => { + render(); + expect(screen.getByText("45")).toBeInTheDocument(); + }); + + it("renders points", () => { + render(); + expect(screen.getByText("250")).toBeInTheDocument(); + }); + + it("renders labels in Portuguese", () => { + render(); + expect(screen.getByText("mini-projetos concluídos")).toBeInTheDocument(); + expect(screen.getByText("reações recebidas")).toBeInTheDocument(); + expect(screen.getByText("pontos")).toBeInTheDocument(); + }); + + it("renders zero values correctly", () => { + render( + , + ); + const zeros = screen.getAllByText("0"); + expect(zeros).toHaveLength(3); + }); +}); diff --git a/app/routes/_layout-app/_perfil/components/profile-stats.tsx b/app/routes/_layout-app/_perfil/components/profile-stats.tsx new file mode 100644 index 00000000..2c19d203 --- /dev/null +++ b/app/routes/_layout-app/_perfil/components/profile-stats.tsx @@ -0,0 +1,51 @@ +import { BsFillHeartFill, BsTools } from "react-icons/bs"; +import { FaMedal } from "react-icons/fa"; +import type { UserProfile } from "~/lib/models/user.server"; + +export default function ProfileStats({ + stats, +}: { + stats: UserProfile["stats"]; +}) { + return ( +
+ } + value={stats.completed_challenge_count} + label="mini-projetos concluídos" + /> + } + value={stats.received_reaction_count} + label="reações recebidas" + /> + } + value={stats.points} + label="pontos" + /> +
+ ); +} + +function StatCard({ + icon, + value, + label, +}: { + icon: React.ReactNode; + value: number; + label: string; +}) { + return ( +
+
+ {icon} + {value} +
+ + {label} + +
+ ); +} diff --git a/app/routes/_layout-app/_perfil/perfil_.$github_user/index.test.tsx b/app/routes/_layout-app/_perfil/perfil_.$github_user/index.test.tsx new file mode 100644 index 00000000..dd13fe62 --- /dev/null +++ b/app/routes/_layout-app/_perfil/perfil_.$github_user/index.test.tsx @@ -0,0 +1,144 @@ +import { screen } from "@testing-library/react"; +import { renderWithRouter } from "~/test/render-with-router"; +import UserProfilePage from "./index"; + +vi.mock("~/lib/models/user.server", () => ({ + getUserProfile: vi.fn(), +})); + +vi.mock("~/components/ui/tooltip", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const mockProfile = { + user: { + name: "maria oliveira", + github_user: "mariaoliveira", + linkedin_user: "maria-oliveira", + avatar: { + avatar_url: "https://example.com/avatar.jpg", + name: "Maria Oliveira", + badge: "pro" as const, + github_user: "mariaoliveira", + }, + created_at: "2024-03-10T12:00:00Z", + }, + stats: { + points: 350, + completed_challenge_count: 15, + received_reaction_count: 60, + }, + completed_challenges: [ + { + id: 1, + submission_image_url: "https://example.com/sub1.jpg", + submission_url: "https://github.com/maria/project1", + submitted_at: "2024-06-01T12:00:00Z", + challenge: { + name: "Landing Page com Tailwind", + slug: "landing-page-tailwind", + image_url: "https://example.com/challenge1.png", + }, + }, + { + id: 2, + submission_image_url: "https://example.com/sub2.jpg", + submission_url: "https://github.com/maria/project2", + submitted_at: "2024-07-15T12:00:00Z", + challenge: { + name: "Dashboard com React", + slug: "dashboard-react", + image_url: "https://example.com/challenge2.png", + }, + }, + ], + certificates: [ + { + id: "cert1", + metadata: { + certifiable_source_name: "Workshop React Avançado", + certifiable_slug: "react-avancado", + }, + status: "published", + created_at: "2024-08-01T12:00:00Z", + }, + ], +}; + +describe("UserProfilePage route", () => { + it("renders user name", async () => { + renderWithRouter(UserProfilePage, { + path: "/perfil/:github_user", + initialEntries: ["/perfil/mariaoliveira"], + loader: () => ({ profile: mockProfile }), + }); + + expect(await screen.findByText("Maria Oliveira")).toBeInTheDocument(); + }); + + it("renders stats", async () => { + renderWithRouter(UserProfilePage, { + path: "/perfil/:github_user", + initialEntries: ["/perfil/mariaoliveira"], + loader: () => ({ profile: mockProfile }), + }); + + expect(await screen.findByText("350")).toBeInTheDocument(); + expect(screen.getByText("15")).toBeInTheDocument(); + expect(screen.getByText("60")).toBeInTheDocument(); + }); + + it("renders completed challenges", async () => { + renderWithRouter(UserProfilePage, { + path: "/perfil/:github_user", + initialEntries: ["/perfil/mariaoliveira"], + loader: () => ({ profile: mockProfile }), + }); + + expect( + await screen.findByText("Landing Page com Tailwind"), + ).toBeInTheDocument(); + expect(screen.getByText("Dashboard com React")).toBeInTheDocument(); + }); + + it("renders certificates", async () => { + renderWithRouter(UserProfilePage, { + path: "/perfil/:github_user", + initialEntries: ["/perfil/mariaoliveira"], + loader: () => ({ profile: mockProfile }), + }); + + expect( + await screen.findByText("Workshop React Avançado"), + ).toBeInTheDocument(); + }); + + it("renders GitHub link", async () => { + renderWithRouter(UserProfilePage, { + path: "/perfil/:github_user", + initialEntries: ["/perfil/mariaoliveira"], + loader: () => ({ profile: mockProfile }), + }); + + expect(await screen.findByText("@mariaoliveira")).toBeInTheDocument(); + }); + + it("shows empty states when no challenges or certificates", async () => { + const emptyProfile = { + ...mockProfile, + completed_challenges: [], + certificates: [], + }; + + renderWithRouter(UserProfilePage, { + path: "/perfil/:github_user", + initialEntries: ["/perfil/mariaoliveira"], + loader: () => ({ profile: emptyProfile }), + }); + + expect( + await screen.findByText("Nenhum mini-projeto concluído ainda."), + ).toBeInTheDocument(); + expect(screen.getByText("Nenhum certificado ainda.")).toBeInTheDocument(); + }); +}); diff --git a/app/routes/_layout-app/_perfil/perfil_.$github_user/index.tsx b/app/routes/_layout-app/_perfil/perfil_.$github_user/index.tsx new file mode 100644 index 00000000..970f17a8 --- /dev/null +++ b/app/routes/_layout-app/_perfil/perfil_.$github_user/index.tsx @@ -0,0 +1,105 @@ +import { + Link, + isRouteErrorResponse, + useLoaderData, + useRouteError, +} from "react-router"; +import type { LoaderFunctionArgs, MetaFunction } from "react-router"; +import invariant from "tiny-invariant"; +import { getUserProfile } from "~/lib/models/user.server"; +import { formatName } from "~/lib/utils/format-name"; +import { getOgGeneratorUrl } from "~/lib/utils/path-utils"; +import ProfileHeader from "../components/profile-header"; +import ProfileStats from "../components/profile-stats"; +import ProfileChallenges from "../components/profile-challenges"; +import ProfileCertificates from "../components/profile-certificates"; + +export const meta: MetaFunction = ({ data, params }) => { + if (!data?.profile) { + return [{ title: "Perfil não encontrado | Codante.io" }]; + } + + const { profile } = data; + const name = formatName(profile.user.name); + const title = `${name} | Codante.io`; + const description = `Veja o perfil de ${name} no Codante. ${profile.stats.completed_challenge_count} mini-projetos concluídos, ${profile.stats.points} pontos.`; + const imageUrl = getOgGeneratorUrl(name, "Perfil"); + + return [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:image", content: imageUrl }, + { + property: "og:url", + content: `https://codante.io/perfil/${params.github_user}`, + }, + { property: "og:type", content: "profile" }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: imageUrl }, + ]; +}; + +export async function loader({ params }: LoaderFunctionArgs) { + invariant(params.github_user, "params.github_user is required"); + + const profile = await getUserProfile(params.github_user); + + if (!profile) { + throw new Response("Not Found", { status: 404 }); + } + + return { profile }; +} + +export function ErrorBoundary() { + const error = useRouteError(); + + if (isRouteErrorResponse(error) && error.status === 404) { + return ( +
+

404

+

+ Perfil não encontrado +

+

+ Não encontramos nenhum usuário com esse nome. +

+ + Voltar para a Home + +
+ ); + } + + return ( +
+

Erro

+

+ Algo deu errado +

+ + Voltar para a Home + +
+ ); +} + +export default function UserProfilePage() { + const { profile } = useLoaderData(); + + return ( +
+ + + + +
+ ); +} diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 0aced6d6..1a10271f 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -200,7 +200,11 @@ function Challenges() {
{orderedChallengeList.map((challenge) => (
- +
))}