Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5b97ade
refactor: #202 tasklist modal 할일 내용 margin조절
nidor022 Dec 2, 2025
e546915
Merge remote-tracking branch 'origin/main' into feat/taskQA
nidor022 Dec 2, 2025
e17baef
feat: #93 DeveloperCard 컴포넌트 내용 추가
suuuuya Dec 2, 2025
4de8038
Merge pull request #203 from codeit18-4-5/feat/taskQA
nidor022 Dec 2, 2025
2d4bc98
fix: #194 유저 스키마 trim 추가
sohyun0 Dec 2, 2025
9c6633e
fix: #194 비밀번호 재설정 여백 추가
sohyun0 Dec 2, 2025
3b9171f
fix: #194 닉네임 maxLength 추가
sohyun0 Dec 2, 2025
b3804e3
fix: #194 비밀번호 재설정 눈 아이콘 tab index 추가
sohyun0 Dec 2, 2025
b3a137a
fix: #93 랜딩페이지 ProductDemo 컴포넌트 반대로 적용된 아이콘 수정
suuuuya Dec 2, 2025
29ec248
Merge pull request #205 from codeit18-4-5/fix/user
sohyun0 Dec 2, 2025
c9e62e9
feat: #206 404페이지 구현
sejin5 Dec 2, 2025
b69050c
refactor: #196 사용자 로그인, 팀 탈퇴 수정 변경에 따른 동기화 반영
sejin5 Dec 2, 2025
74f07fc
refactor: #196 팀 삭제 후 헤더 드롭다운 반영
sejin5 Dec 2, 2025
9cf60d1
refactor: #196 팀명 수정 후 헤더 드롭다운 반영
sejin5 Dec 2, 2025
8c5c2da
refactor: #198 invalidate 되도록 수정
sejin5 Dec 2, 2025
a3ec65b
fix: #198 팀 참여하기/생성하기 공란일 경우 alert
sejin5 Dec 2, 2025
f4afd67
Merge pull request #204 from codeit18-4-5/refactor/landing-bottom
suuuuya Dec 3, 2025
3f7bfae
refactor: 팀페이지, 헤더 드롭다운 개선 / 404 페이지 구현
sejin5 Dec 3, 2025
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
47 changes: 47 additions & 0 deletions public/assets/images/img-404.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/landing/img-avatar-2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions src/api/team/get-ssr-user-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { devConsoleError } from "@/lib/error";
import { serverFetch } from "@/lib/server/server-fetch";
import { GetUserGroup } from "@/types/group";

const getSSRUserGroups = async (): Promise<GetUserGroup[]> => {
try {
return await serverFetch(`/user/groups`, {
method: "GET",
cache: "no-store",
});
} catch (e) {
devConsoleError(e);
throw e;
}
};

export default getSSRUserGroups;
21 changes: 6 additions & 15 deletions src/app/(routes)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,24 @@
import { useState, useEffect } from "react";
import { useAuthStore } from "@/store/auth.store";
import { Header } from "@/components/layout";
import { DropdownOption } from "@/types/option";

export default function RoutesLayout({ children }: { children: React.ReactNode }) {
const [group, setGroup] = useState<DropdownOption[] | null>(null);
const [isLogin, setIsLogin] = useState(false);

const user = useAuthStore(state => state.user);
const initialized = useAuthStore(state => state.initialized);

useEffect(() => {
if (user) {
const userGroupInfo =
user.memberships?.map(mb => {
const groupInfo: DropdownOption = {
id: mb.group.id,
name: mb.group.name,
image: mb.group.image,
};
return groupInfo;
}) || [];
setGroup(userGroupInfo);
setIsLogin(true);
} else {
setGroup(null);
setIsLogin(false);
}
}, [user]);
}, [initialized, user]);

return (
<>
<Header isLoginPage={false} groups={group} user={user} />
<Header isLoginPage={false} user={user} isLogin={isLogin} />
{children}
</>
);
Expand Down
1 change: 1 addition & 0 deletions src/app/(routes)/team/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default function TeamEditPagae() {
queryKey: ["getGroups", groupId],
});
sessionStorage.setItem("teamEditMessage", "팀이 수정되었습니다.");
queryClient.invalidateQueries({ queryKey: ["getUser"] });
router.replace(`/team/${groupId}`);
},
onError: error => {
Expand Down
21 changes: 16 additions & 5 deletions src/app/(routes)/team/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
import { notFound } from "next/navigation";
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { getGroupTaskListsforServer } from "@/api/tasklist/index-server";
import getSSRUserGroups from "@/api/team/get-ssr-user-groups";
import getSSRUser from "@/api/user/get-ssr-user";
import TeamClientPages from "./team-client";

export const dynamic = "force-dynamic";

export default async function TeamPages({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;

const groupId = Number(id);
const queryClient = new QueryClient();

const groupData = await getGroupTaskListsforServer(groupId);
const userGroup = await getSSRUserGroups();
const user = await getSSRUser();

const isMember = userGroup.some(ug => ug.id === groupId);

if (!groupData) {
if (!groupData || !isMember) {
notFound();
}
//getusergroups도 ssr api 만들어서 여기서 조회해서 넘겨.
const memberList = user?.memberships?.filter(mb => mb.groupId === Number(groupId)) || [
{ role: "ADMIN" },
];

queryClient.setQueryData(["getGroups", groupId], groupData);

const userRole = memberList[0].role;

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TeamClientPages groupId={groupId} groups={groupData} />
<TeamClientPages groupId={groupId} userRole={userRole} />
</HydrationBoundary>
);
}
33 changes: 12 additions & 21 deletions src/app/(routes)/team/[id]/team-client.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { redirect, notFound } from "next/navigation";
import { redirect } from "next/navigation";
import { useState, useEffect } from "react";
import { Container } from "@/components/layout";
import { GetGroupsResponse, TodoListProps } from "@/types/group";
Expand All @@ -8,23 +8,29 @@ import { TeamTitle, TodoList, TeamMember, TeamReport } from "@/components/featur
import { useAuthStore } from "@/store/auth.store";
import { useToast } from "@/providers/toast-provider";
import TeamSkeleton from "@/components/skeleton-ui/team-skeleton";
import { useQuery } from "@tanstack/react-query";
import getGroups from "@/api/team/get-groups";

export default function TeamClientPages({
groupId,
groups,
userRole,
}: {
groupId: number;
groups: GetGroupsResponse;
userRole: string;
}) {
const { showToast } = useToast();

const user = useAuthStore(state => state.user);
const initialized = useAuthStore(state => state.initialized);

const [userRole, setUserRole] = useState("MEMBER");
const [members, setMembers] = useState<Member[]>([]);
const [todoLists, setTodoLists] = useState<TodoListProps>();

const { data: groups } = useQuery<GetGroupsResponse, Error>({
queryKey: ["getGroups", groupId],
queryFn: () => getGroups(groupId),
});

useEffect(() => {
setTimeout(() => {
const teamJoinMessage = sessionStorage.getItem("teamJoinMessage");
Expand All @@ -42,15 +48,6 @@ export default function TeamClientPages({
}
}, [groups]);

useEffect(() => {
if (user?.memberships) {
const isBeing = user.memberships.filter(mb => mb.groupId === Number(groupId));
if (isBeing[0]?.role) {
setUserRole(isBeing[0].role);
}
}
}, [user]);

if (!initialized) {
return <TeamSkeleton />;
}
Expand All @@ -59,20 +56,14 @@ export default function TeamClientPages({
redirect("/");
}

const isMember = user.memberships?.some(mb => mb.groupId === groupId);

if (!isMember) {
notFound();
}

const { id: userId } = user;

return (
<Container>
<TeamTitle name={groups.name} id={groups.id} userRole={userRole} />
<TeamTitle name={groups?.name || ""} id={groups?.id || 0} userRole={userRole} />
<TodoList groupId={todoLists?.groupId as number} taskList={todoLists?.taskList || []} />
<TeamReport taskLists={todoLists?.taskList || []} />
<TeamMember members={members} userId={userId} userRole={userRole} groupId={groups.id} />
<TeamMember members={members} userId={userId} userRole={userRole} groupId={groupId} />
</Container>
);
}
11 changes: 10 additions & 1 deletion src/app/(routes)/team/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import IcProfile from "@/assets/icons/ic-image-circle.svg";
import IcEdit from "@/assets/icons/ic-pencil-border.svg";
import { devConsoleError } from "@/lib/error";
import { useToast } from "@/providers/toast-provider";
import { useQueryClient } from "@tanstack/react-query";
import { useAlert } from "@/providers/alert-provider";

export default function TeamCreatePage() {
const router = useRouter();
const { showToast } = useToast();
const { showAlert } = useAlert();
const queryClient = useQueryClient();

const [formData, setFormData] = useState<GroupCreateRequest>({
name: "",
Expand Down Expand Up @@ -59,7 +63,8 @@ export default function TeamCreatePage() {
mutationFn: postGroups,
onSuccess: res => {
sessionStorage.setItem("teatCreateMessage", "팀이 생성되었습니다.");
router.push(`/team/${res.id}`);
queryClient.invalidateQueries({ queryKey: ["getUser"] });
router.replace(`/team/${res.id}`);
},
onError: error => {
devConsoleError(error);
Expand All @@ -69,6 +74,10 @@ export default function TeamCreatePage() {

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (formData.name.length === 0) {
showAlert("팀 명은 공란일 수 없습니다.");
return;
}
if (selectedImgFile) {
uploadImageMutate.mutate({ url: selectedImgFile });
} else {
Expand Down
10 changes: 10 additions & 0 deletions src/app/(routes)/team/join/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import postTeamJoin from "@/api/team/post-join-team";
import { useToast } from "@/providers/toast-provider";
import axios from "axios";
import { devConsoleError } from "@/lib/error";
import { useQueryClient } from "@tanstack/react-query";
import { useAlert } from "@/providers/alert-provider";

export default function TeamJoinPage() {
const router = useRouter();
const queryClient = useQueryClient();
const user = useAuthStore(state => state.user);
const { showToast } = useToast();
const { showAlert } = useAlert();

const [formData, setFormData] = useState<GroupJoinRequest>({
userEmail: "",
Expand All @@ -38,7 +42,9 @@ export default function TeamJoinPage() {
mutationFn: postTeamJoin,
onSuccess: res => {
sessionStorage.setItem("teamJoinMessage", "팀에 합류했습니다.");
queryClient.invalidateQueries({ queryKey: ["getUser"] });
setFormData(fd => ({ ...fd, token: "" }));

router.replace(`/team/${res.groupId}`);
},
onError: error => {
Expand All @@ -60,6 +66,10 @@ export default function TeamJoinPage() {

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (formData.token.length === 0) {
showAlert("유효한 토큰을 입력해주세요");
return;
}
joinMutation.mutate(formData);
};
return (
Expand Down
31 changes: 31 additions & 0 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Image from "next/image";
import { Container } from "@/components/layout";
import { Button } from "@/components/ui";
import { Header } from "@/components/layout";
import ButtonNotFound from "@/components/features/not-found/button-404";

export default function NotFound() {
return (
<>
<Header isLoginPage={true} />
<Container className="mt-[15vh] tablet:mt-[20vh]">
<Image
src="/assets/images/img-404.svg"
alt=""
width={300}
height={60}
className="mx-auto"
/>
<p className="mt-[32px] text-center text-gray-500 tablet:mt-[48px]">
페이지를 찾을 수 없습니다.
</p>
<div className="mx-auto mt-[48px] flex w-[350px] items-center gap-2 tablet:mt-[80px]">
<ButtonNotFound />
<Button intent="primary" className="h-[48px] w-[180px]" as="a" href="/">
홈으로 이동
</Button>
</div>
</Container>
</>
);
}
2 changes: 1 addition & 1 deletion src/components/features/article/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function ArticleConfirmModal({
}: ArticleConfirmModalProps) {
return (
<Modal isOpen={true} onClose={handleClose}>
<div className="p-[16px_0_32px]">
<div className="p-[16px_0]">
<Modal.HeaderWithOnlyTitle title={title} />
<div className="pb-[24px]">
<p className="text-center text-caption text-gray-300">{message}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/components/features/auth/forgot-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function ForgotPassword({ isOpen, onClose }: ForgotPasswordProps)

return (
<Modal isOpen={isOpen} onClose={onClose}>
<div className="mt-4 flex flex-col gap-6">
<div className="mb-6 mt-4 flex flex-col gap-6">
<div className="text-center">
<p className="mb-2 text-base">비밀번호 재설정</p>
<p className="text-sm text-gray-300">비밀번호 재설정 링크를 보내드립니다.</p>
Expand Down
6 changes: 5 additions & 1 deletion src/components/features/auth/form-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ export function SignUpFormFields({ isPending }: { isPending: boolean }) {
caption="(닉네임 중복 불가, 최대 10자)"
errorMsg={errors?.nickname?.message}
>
<Input.Field {...register("nickname")} placeholder="닉네임을 입력해주세요." />
<Input.Field
{...register("nickname")}
placeholder="닉네임을 입력해주세요."
maxLength={10}
/>
</AuthField>

<AuthField
Expand Down
12 changes: 7 additions & 5 deletions src/components/features/landing/developer-story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,28 @@ const developerReviews = [
name: "소현",
role: "Front-end Lead",
avatar: "/assets/landing/img-avatar-1.jpg",
review: "내용",
review: `PLANGO에서 로그인 → 히스토리 → 프로필 관리까지, 유저의 주요 흐름을 담당한 만큼 "사용하기 편하다" 라고 느낄 수 있도록 고민하며 작업했습니다. 로그인을 반복해야 하는 불편함을 줄이기 위해 로그인 유지 기능을 구현했고, 보안을 위해 핵심 토큰은 httpOnly 쿠키로 안전하게 관리했습니다. 마이히스토리는 데이터가 많아져도 빠르게 확인할 수 있도록 SSR과 CSR을 적절히 나누어 처리하고, 실패시 재시도 기능을 넣어 이탈률을 최소화했습니다. 또한 프로필 사진도 업로드 후 바로 취소할 수 있어 부담 없이 여러 번 시도할 수 있도록 편의성을 높였습니다`,
},
{
name: "루리",
role: "Front-end Developer",
avatar: "/assets/landing/img-avatar-4.jpeg",
review: "내용",
avatar: "/assets/landing/img-avatar-2.jpg",
review:
"Plango의 할일 페이지를 개발하였습니다. 초기 로딩속도를 줄이기위해서 SSR기능을 적용하였고, 따라서 할일리스트에서 첫페이지의 데이터를 가져올때 SSR, CSR로 나누어서 데이터를 가져오도록 했습니다. 그리고 provider로 context를 공유하는등 컴포넌트간의 데이터 드릴링을 막기위해서 provider를 적용하였습니다. 그리고 처음으로 가장 신경썼던게 Alert인데요. 모든 화면과 다양한 상황에서 즉시 사용 가능한 기본형을 제공하는 동시에, 디자인 및 로직 변경에 쉽게 대응할 수 있는 커스텀 옵션을 지원하도록 설계했습니다",
},
{
name: "세진",
role: "Front-end Developer",
avatar: "/assets/landing/img-avatar-3.jpg",
review: "내용",
review:
"Plango의 gnb와 팀 페이지를 담당했습니다! 각자 팀마다 그래프를 통해 오늘의 달성률을 한눈에 볼 수 있다는것이 매력적이라고 생각합니다. 자유롭게 팀 생성 및 참여가 가능하고 팀 페이지 내에서 멤버관리, 할 일 목록 관리를 간단하게 할 수 있습니다. GNB를 처음 구현해보았는데 사용자 정보와, 그에 따라 달라지는 데이터를 관리하는 부분이라 SSR과 CSR을 조합하여 구성하였습니다.",
},
{
name: "연수",
role: "Front-end Developer",
avatar: "/assets/landing/img-avatar-4.jpeg",
review:
"Plango 자유게시판은 사람들이 모여 이야기를 나누고 작은 교류가 하나의 문화로 이어지는 공간이 되기를 바랐습니다. 버튼 하나로 팀에 참여할 수 있는 초대 기능을 게시판에 추가 도입하고, 게시글 좋아요 기능에 낙관적 UI 업데이트를 적용해 서버 지연이 발생하더라도 즉시 반응이 가능하도록 개선함으로써 게시판의 참여 속도와 사용자 경험을 최적화했습니다.",
"Plango 자유게시판은 사람들이 모여 이야기를 나누고 작은 교류가 하나의 문화로 이어지는 공간이 되기를 바랐습니다. 버튼 하나로 팀에 참여할 수 있는 초대 기능을 게시판에 추가 도입하고, 사용자에게 빠른 피드백을 반영하기 위해 낙관적 업데이트를 적용해 서버 지연이 발생하더라도 즉시 반응이 가능하도록 개선함으로써 게시판의 참여 속도와 사용자 경험을 최적화했습니다.",
},
];

Expand Down
2 changes: 1 addition & 1 deletion src/components/features/landing/popular-posts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function PopularPosts() {
const [showFloatingHearts, setShowFloatingHearts] = useState(false);
const [floatingHeartsData] = useState(() => generateFloatingHearts(35));
const sectionRef = useRef<HTMLDivElement>(null);
const isInView = useInView(sectionRef, { amount: 0.25, once: false });
const isInView = useInView(sectionRef, { amount: 0.2, once: true });

useEffect(() => {
if (isInView && !showFloatingHearts) {
Expand Down
6 changes: 3 additions & 3 deletions src/components/features/landing/product-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const mockTasks = [
},
{
id: 3,
title: "빌표자료 준비하기",
title: "발표자료 준비하기",
completed: false,
frequencyType: "한 번",
},
Expand Down Expand Up @@ -134,7 +134,7 @@ export default function ProductDemo() {
{tasks.length}개
</b>
</span>
<IcDone className="h-[40px] w-[40px]" />
<IcTodo className="h-[40px] w-[40px]" />
</div>
<div className="flex items-center justify-between rounded-[12px] bg-gray-700 p-[15px_20px]">
<span className="grid gap-[4px]">
Expand All @@ -143,7 +143,7 @@ export default function ProductDemo() {
{tasks.filter(t => t.completed).length}개
</b>
</span>
<IcTodo className="h-[40px] w-[40px]" />
<IcDone className="h-[40px] w-[40px]" />
</div>
</div>
</div>
Expand Down
Loading