Skip to content
1 change: 0 additions & 1 deletion src/app/taskmate/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { NavigationBar } from "@/components/NavigationBar";
import NavigationBarProvider from "@/components/NavigationBar/provider";

import NotificationSubscriber from "@/features/notification/NotificationSubscriber";

export default function TaskmateLayout({
Expand Down
2 changes: 1 addition & 1 deletion src/app/taskmate/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import WelcomeBanner from "@/components/home/WelcomeBanner";

export default function TaskmatePage() {
return (
<div className="tablet:px-6 tablet:py-16 desktop:px-10 desktop:py-20 flex w-full max-w-full min-w-0 flex-col items-center justify-center px-5 py-8">
<div className="tablet:px-6 tablet:py-16 desktop:px-22 desktop:py-20 tablet:w-[calc(100dvw-60px)] desktop:w-[calc(100dvw-360px)] flex w-full max-w-full min-w-0 flex-col items-center justify-center px-5 py-8">
<AsyncBoundary errorFallback={<div>error</div>}>
<WelcomeBanner />
</AsyncBoundary>
Expand Down
47 changes: 32 additions & 15 deletions src/app/taskmate/team/[teamId]/management/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"use client";

import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useState } from "react";

import TextButton from "@/components/common/TextButton/TextButton";
import DeleteModal from "@/components/management/DeleteModal";
import ErrorModal from "@/components/management/ErrorModal";
import InviteModal from "@/components/management/InviteModal";
import MemberList from "@/components/management/MemberList";
import TeamNameEditor from "@/components/management/TeamNameEditor";
Expand All @@ -17,6 +18,8 @@ const TeamManagement = () => {
const router = useRouter();
const params = useParams<{ teamId: string }>();
const teamId = params.teamId;
const [errorMessage, setErrorMessage] = useState("");
const [errorModalOpen, setErrorModalOpen] = useState(false);

const handleOpenInvite = () => {
open(
Expand All @@ -40,6 +43,10 @@ const TeamManagement = () => {
close();
router.replace("/taskmate");
}}
onError={(message) => {
setErrorMessage(message);
setErrorModalOpen(true);
}}
/>,
);
};
Expand All @@ -66,21 +73,31 @@ const TeamManagement = () => {
}, [teamId, router]);

return (
<main className="relative flex w-full flex-col items-center">
<div className="tablet:gap-6 relative flex flex-col gap-4">
<h1 className="tablet:block typography-title-3 hidden">팀 정보 수정</h1>
<div className="tablet:gap-6 flex w-140 flex-col gap-4">
<TeamNameEditor />
<MemberList onInviteClick={handleOpenInvite} />
<TextButton
onClick={handleOpenDelete}
className="tablet:w-fit ml-auto w-full"
>
팀 삭제하기
</TextButton>
<section>
<main className="tablet:px-6 tablet:py-16 desktop:px-22 desktop:py-20 relative mx-auto flex w-full min-w-0 flex-col items-center justify-center px-5 py-8">
<div className="tablet:gap-6 relative mx-auto flex w-full max-w-140 flex-col gap-4">
<h1 className="tablet:block typography-title-3 hidden">
팀 정보 수정
</h1>
<div className="flex w-full flex-col gap-4">
<TeamNameEditor />
<MemberList onInviteClick={handleOpenInvite} />
<TextButton
onClick={handleOpenDelete}
className="tablet:w-fit ml-auto w-full"
>
팀 삭제하기
</TextButton>
</div>
</div>
</div>
</main>
</main>

<ErrorModal
message={errorMessage}
isOpen={errorModalOpen}
onClose={() => setErrorModalOpen(false)}
/>
</section>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const NotificationPanel = ({ onClose }: Props) => {
};

return (
<div className="bg-background-normal fixed bottom-30 left-50 z-999 rounded-3xl border border-gray-200 px-3 py-5 shadow-[0_0_30px_0_rgba(0,0,0,0.05)]">
<div className="bg-background-normal z-999 rounded-3xl border border-gray-200 px-3 py-5 shadow-[0_0_30px_0_rgba(0,0,0,0.05)]">
<div className="mb-4 flex justify-between">
<span className="typography-label-1 text-label-neutral my-1.25 ml-2 font-semibold">
알림
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,96 @@
"use client";
import React, { useState } from "react";

import { Icon } from "@/components/common/Icon/index";
import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { cva } from "class-variance-authority";
import { useEffect, useRef, useState } from "react";

import { Icon } from "@/components/common/Icon";
import { notificationInfiniteQueries } from "@/features/notification/query/notificationInfiniteQueries";
import { cn } from "@/utils/utils";

import NotificationPanel from "./NotificatioPanel";

const NotificationPopover = () => {
const buttonVariants = cva(
"cursor-pointer bg-inverse-normal flex shrink-0 items-center justify-center rounded-[99px]",
{
variants: {
placement: {
default: "size-16 ring-1 ring-gray-300 ring-inset",
aside: "size-[30px]", // bell(30)와 동일
},
},
defaultVariants: {
placement: "default",
},
},
);

interface NotificationPopoverProps {
placement?: "aside";
}

const NotificationPopover = ({ placement }: NotificationPopoverProps) => {
const [open, setOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);

const handleClick = () => setOpen((prev) => !prev);

useEffect(() => {
if (!open) return;

const handlePointerDownOutside = (event: PointerEvent) => {
const target = event.target as Node;
if (!popoverRef.current?.contains(target)) {
setOpen(false);
}
};

document.addEventListener("pointerdown", handlePointerDownOutside);
return () =>
document.removeEventListener("pointerdown", handlePointerDownOutside);
}, [open]);

// 알림 표시
const { data: notificationData } = useSuspenseInfiniteQuery(
notificationInfiniteQueries.notificationsInfinite(),
);

const hasUnread =
notificationData?.pages.some((page) =>
page.items.some((item) => item.isRead === false),
) ?? false;

return (
<div className="relative">
<div
className="relative"
ref={popoverRef}
>
<button
className="bg-inverse-normal flex size-16 shrink-0 items-center justify-center rounded-[99px] lg:ring-1 lg:ring-gray-300 lg:ring-inset"
className={buttonVariants({ placement })}
onClick={handleClick}
>
<Icon
name="Bell"
size={24}
className="p-1 text-gray-500"
size={placement === "aside" ? 30 : 24}
className="text-gray-500"
/>

{hasUnread && (
<span className="absolute top-0.75 right-0.75 h-3 w-3 rounded-full bg-green-800" />
)}
</button>

{open && <NotificationPanel onClose={() => setOpen(false)} />}
{open && (
<div
className={cn(
"fixed z-50",
placement === "aside" ? "top-40 left-12" : "bottom-30 left-50",
)}
onPointerDown={(e) => e.stopPropagation()}
>
<NotificationPanel onClose={() => setOpen(false)} />
</div>
)}
</div>
);
};
Expand Down
15 changes: 12 additions & 3 deletions src/components/NavigationBar/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { useRouter } from "next/navigation";
import type { ComponentProps, ReactElement } from "react";
Expand Down Expand Up @@ -35,6 +36,11 @@ jest.mock("@/components/common/LogoutButton", () => ({
default: () => null,
}));

jest.mock("@/components/NavigationBar/NotificationPopover", () => ({
__esModule: true,
default: () => null,
}));

jest.mock("@/components/common/Icon", () => ({
Icon: ({ name }: { name: string }) => <span data-testid={`icon-${name}`} />,
}));
Expand All @@ -47,6 +53,7 @@ function renderWithNavContext(
ui: ReactElement,
value: Partial<ContextValue> = {},
) {
const queryClient = new QueryClient();
const defaults: ContextValue = {
isOpen: true,
open: jest.fn(),
Expand All @@ -57,9 +64,11 @@ function renderWithNavContext(
};

return render(
<NavigationBarContext.Provider value={defaults}>
{ui}
</NavigationBarContext.Provider>,
<QueryClientProvider client={queryClient}>
<NavigationBarContext.Provider value={defaults}>
{ui}
</NavigationBarContext.Provider>
</QueryClientProvider>,
);
}

Expand Down
9 changes: 6 additions & 3 deletions src/components/NavigationBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const navigationBarAsideVariants = cva(
variants: {
open: {
true: "h-screen overflow-y-scroll mobile:w-[360px] mobile:p-8",
false: "h-[56px] mobile:w-[60px] mobile:px-3 mobile:py-8",
false:
"h-[56px] mobile:w-[60px] mobile:px-3 mobile:py-8 gap-6 items-center",
},
},
defaultVariants: { open: false },
Expand All @@ -53,7 +54,7 @@ export const NavigationBar = () => {
style={{ willChange: "width, height", zIndex: NAVIGATION_BAR_ZINDEX }}
>
<Header />
{/* <NotificationPopover /> */}
{!isOpen && <NotificationPopover placement="aside" />}

<Spacing
size={20}
Expand Down Expand Up @@ -111,7 +112,9 @@ export const NavigationBar = () => {
<UserProfile />
</AsyncBoundary>

<NotificationPopover />
<AsyncBoundary>
<NotificationPopover />
</AsyncBoundary>

<LogoutButton />
</div>
Expand Down
6 changes: 4 additions & 2 deletions src/components/home/FavoriteGoalsItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface FavoriteGoalsItemProps {
goal: {
goalId: number;
goalName: string;
progressPercent: number;
isFavorite: boolean;
};
}

Expand All @@ -25,9 +27,9 @@ export function FavoriteGoalsItem({
teamId={String(teamId)}
goalId={goal.goalId}
title={goal.goalName}
progress={0}
progress={goal.progressPercent}
color="green"
isFavorite={false}
isFavorite={goal.isFavorite}
className="w-full shrink-0 bg-green-100"
/>
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/components/home/FavoriteGoalsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";

Expand All @@ -13,6 +14,7 @@ import { useInfiniteScroll } from "@/hooks/useInfiniteScroll/useInfiniteScroll";

export function FavoriteGoalsSection() {
const router = useRouter();
const queryClient = useQueryClient();

const { ref, data, isFetchingNextPage } = useInfiniteScroll(
mainInfiniteQueries.favoriteGoalsInfinite(),
Expand Down Expand Up @@ -74,6 +76,20 @@ export function FavoriteGoalsSection() {
return () => container.removeEventListener("scroll", updateScrollState);
}, []);

useEffect(() => {
const handleFavoriteToggled = () => {
queryClient.invalidateQueries({ queryKey: ["favoriteGoals"] });
};

window.addEventListener("goal-favorite-toggled", handleFavoriteToggled);
return () => {
window.removeEventListener(
"goal-favorite-toggled",
handleFavoriteToggled,
);
};
}, [queryClient]);

if (items.length === 0) {
return (
<div className="flex h-52.5 w-full flex-col items-center justify-center gap-1 rounded-4xl bg-white">
Expand Down
30 changes: 19 additions & 11 deletions src/components/management/DeleteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
"use client";

import { useRouter } from "next/navigation";

import Button from "@/components/common/Button/Button";
import TextButton from "@/components/common/TextButton/TextButton";

interface DeleteModalProps {
onClose: () => void;
onSubmitDelete: () => Promise<void>;
onError: (message: string) => void;
}

// @TODO: onSubmitDelete 함수를 Page에서 받아오는 방식 제거 ( Page가 갖는 책임 아님 )
const DeleteModal = ({ onClose, onSubmitDelete }: DeleteModalProps) => {
const router = useRouter();

const DeleteModal = ({
onClose,
onSubmitDelete,
onError,
}: DeleteModalProps) => {
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();

// @TODO: useMutation 으로 리팩토링
// @TODO: console 제거
try {
await onSubmitDelete();
onClose();
router.replace("/taskmate");
} catch (error) {
console.log("팀 삭제 실패", error);
} catch (error: unknown) {
onClose();
const message =
typeof error === "object" &&
error !== null &&
"data" in error &&
typeof (error as { data?: { message?: unknown } }).data?.message ===
"string"
? ((error as { data: { message: string } }).data.message ?? "")
: "팀 삭제에 실패했습니다.";
onError(message);
}
};

Expand All @@ -37,8 +45,8 @@ const DeleteModal = ({ onClose, onSubmitDelete }: DeleteModalProps) => {
<p className="typography-body-1 mt-1.5 text-gray-400">
팀 페이지와 팀 정보를 삭제합니다.
</p>
<p className="typography-body-2 mt-3 text-blue-700">
<span></span>삭제된 팀 페이지는 복구할 수 없어요
<p className="typography-body-2 py-3 text-blue-700">
삭제된 팀 페이지는 복구할 수 없어요.
</p>
</div>
<form
Expand Down
Loading
Loading