Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions app/components/_layouts/navbar/profile-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <ProfileMenu user={baseUser} />);

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(() => <ProfileMenu user={userWithoutGithub} />);

await user.click(screen.getByRole("button"));

expect(
screen.queryByRole("menuitem", { name: "Meu Perfil" }),
).not.toBeInTheDocument();
});
});
17 changes: 17 additions & 0 deletions app/components/_layouts/navbar/profile-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Menu as="div" className="relative z-50">
<div>
Expand Down Expand Up @@ -65,6 +67,21 @@ export default function ProfileMenu({ user }: { user: User }) {
</Link>
)}
</Menu.Item>
{githubUser && (
<Menu.Item>
{({ active }) => (
<Link
to={`/perfil/${githubUser}`}
className={classNames(
active ? "dark:bg-background-800/50 bg-background-50" : "",
"block px-4 py-2 text-sm dark:text-gray-50 text-gray-700",
)}
>
Meu Perfil
</Link>
)}
</Menu.Item>
)}
<Menu.Item>
{({ active }) => (
<Form action="/logout" method="post">
Expand Down
19 changes: 19 additions & 0 deletions app/components/ui/cards/challenge-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-testid="avatar-hover-card">{children}</div>
),
}));

const mockChallenge = {
id: 1,
name: "Landing Page com Tailwind",
Expand Down Expand Up @@ -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(() => <ChallengeCard challenge={mockChallenge as any} />);
expect(screen.queryByTestId("avatar-hover-card")).not.toBeInTheDocument();
});

it("renders avatar hover cards when enabled", () => {
renderWithRouter(() => (
<ChallengeCard challenge={mockChallenge as any} showAvatarHoverCard />
));

expect(screen.getAllByTestId("avatar-hover-card")).toHaveLength(2);
});
});
32 changes: 25 additions & 7 deletions app/components/ui/cards/challenge-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,10 +14,12 @@ export default function ChallengeCard({
challenge,
className = "",
openInNewTab,
showAvatarHoverCard = false,
}: {
challenge: ChallengeCardType;
openInNewTab?: boolean;
className?: string;
showAvatarHoverCard?: boolean;
}) {
return (
<Link
Expand Down Expand Up @@ -75,13 +78,28 @@ export default function ChallengeCard({
<div className="flex justify-between w-full mt-8 gap-1 border-t border-gray-200 dark:border-gray-800 pt-4">
<section className="flex items-center">
<div className="flex -space-x-[9px]">
{challenge?.avatars?.map((avatar, index) => (
<UserAvatar
key={index}
avatar={avatar}
className="size-7 rounded-full ring-2 ring-white dark:ring-background-800"
/>
))}
{challenge?.avatars?.map((avatar, index) =>
showAvatarHoverCard ? (
<UserAvatarHoverCard
key={index}
avatar={avatar}
profileTarget={openInNewTab ? "_blank" : "_self"}
>
<UserAvatar
avatar={avatar}
className="size-7 rounded-full ring-2 ring-white dark:ring-background-800"
showTooltip={false}
/>
</UserAvatarHoverCard>
) : (
<UserAvatar
key={index}
avatar={avatar}
className="size-7 rounded-full ring-2 ring-white dark:ring-background-800"
showTooltip={false}
/>
),
)}
{challenge.enrolled_users_count > 5 && (
<div className="flex size-7 items-center justify-center rounded-full bg-blue-100 text-[9px] font-medium text-blue-800 ring-2 ring-white dark:ring-background-800 dark:bg-blue-900 dark:text-blue-200">
+{challenge.enrolled_users_count - 5}
Expand Down
166 changes: 166 additions & 0 deletions app/components/ui/user-avatar-hover-card/index.tsx
Original file line number Diff line number Diff line change
@@ -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<UserProfile>();
const [isOpen, setIsOpen] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<>
<span
ref={triggerRef}
onMouseEnter={openCard}
onMouseLeave={closeCard}
className="relative z-10"
>
{children}
</span>
{isOpen &&
typeof document !== "undefined" &&
createPortal(
<div
onMouseEnter={keepOpen}
onMouseLeave={closeCard}
style={{
position: "absolute",
top: position.top,
left: position.left,
transform: "translate(-50%, -100%)",
zIndex: 9999,
}}
className="animate-in fade-in-0 zoom-in-95 duration-150"
>
<div className="w-64 rounded-xl border border-background-200 dark:border-background-600 bg-background-50 dark:bg-background-800 p-4 shadow-xl">
<Link
to={`/perfil/${avatar.github_user}`}
prefetch="intent"
target={profileTarget}
rel={profileTarget === "_blank" ? "noreferrer" : undefined}
className="block"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3 mb-3">
<img
src={
avatar.avatar_url ||
"https://screenshot-service1.codante.io/avatars/sm"
}
alt={avatar.name}
className={`w-10 h-10 rounded-full bg-background-400 shrink-0 ${
avatar.badge === "pro"
? "ring-2 ring-amber-400"
: avatar.badge === "admin"
? "ring-2 ring-brand-500"
: "ring-2 ring-white dark:ring-gray-700"
}`}
/>
<div className="min-w-0">
<div className="flex items-center gap-1">
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{formatName(avatar.name)}
</span>
{avatar.badge === "pro" && <ProBadge />}
</div>
<span className="text-xs text-gray-400 dark:text-gray-500">
@{avatar.github_user}
</span>
</div>
</div>

{fetcher.state === "loading" && (
<div className="flex gap-4 animate-pulse">
<div className="h-4 w-16 rounded bg-background-200 dark:bg-background-700" />
<div className="h-4 w-16 rounded bg-background-200 dark:bg-background-700" />
<div className="h-4 w-16 rounded bg-background-200 dark:bg-background-700" />
</div>
)}

{profile && (
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<BsTools className="w-3 h-3 text-brand-500" />
{profile.stats.completed_challenge_count}
</span>
<span className="flex items-center gap-1">
<BsFillHeartFill className="w-3 h-3 text-brand-500" />
{profile.stats.received_reaction_count}
</span>
<span className="flex items-center gap-1">
<FaMedal className="w-3 h-3 text-brand-500" />
{profile.stats.points}
</span>
</div>
)}

<span className="mt-2 block text-[10px] text-brand-400 hover:underline">
Ver perfil completo
</span>
</Link>
</div>
</div>,
document.body,
)}
</>
);
}
Loading
Loading