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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
### 2.3 lint 규칙

- 코드의 마지막엔 항상 세미콜론을 붙힌다.
- api 예외 처리시 shared/lib/api/error-toast.ts의 showErrorToast 함수를 사용한다. 기본적인 예외 toast 문구는 shared/constants/toast.ts에서 도메인별로 관리한다.

---

Expand Down
32 changes: 32 additions & 0 deletions app/group/[id]/edit/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";

const parseRouteGroupId = (id: string): number | null => {
if (!/^\d+$/.test(id)) {
return null;
}

const numericGroupId = Number(id);
return Number.isSafeInteger(numericGroupId) && numericGroupId > 0
? numericGroupId
: null;
};

interface GroupEditLayoutProps {
children: ReactNode;
params: Promise<{ id: string }>;
}

export default async function GroupEditLayout({
children,
params,
}: GroupEditLayoutProps) {
const { id } = await params;
const numericGroupId = parseRouteGroupId(id);

if (numericGroupId === null) {
notFound();
}

return <>{children}</>;
}
56 changes: 56 additions & 0 deletions app/group/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { useGroupMenuQuery } from "@/entities/group/queries/useGroupMenuQuery";
import GroupEditForm from "@/features/group/generate/GroupEditForm";
import { useGroupDetailQuery } from "@/entities/group/queries/useGroupDetailQuery";
import { GroupDetailQueryErrorState } from "@/widgets/group/detail/ui/GroupDetailQueryErrorState";

export default function GroupEditPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const routeGroupId = Array.isArray(params?.id) ? params.id[0] : params?.id;
const { data: group, isError, refetch } = useGroupDetailQuery(routeGroupId);
const {
data: groupMenu,
isError: isMenuError,
isPending: isMenuPending,
refetch: refetchMenu,
} = useGroupMenuQuery(routeGroupId);
const isOwner = groupMenu?.role?.toUpperCase() === "OWNER";

useEffect(() => {
if (!routeGroupId || !groupMenu || isOwner) {
return;
}

router.replace(`/group/${routeGroupId}`);
}, [groupMenu, isOwner, routeGroupId, router]);

if (isError || isMenuError) {
return (
<GroupDetailQueryErrorState
onRetry={() => {
void refetch();
void refetchMenu();
}}
/>
);
}

if (!group || !groupMenu || isMenuPending) {
return null;
}

if (!isOwner) {
return <div className="py-10 text-sm text-muted-foreground">접근 권한을 확인하는 중입니다.</div>;
}

return (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-bold text-foreground">그룹 정보 수정</h1>
<GroupEditForm group={group} />
</div>
);
}
197 changes: 130 additions & 67 deletions app/group/[id]/members/page.tsx
Original file line number Diff line number Diff line change
@@ -1,96 +1,159 @@
"use client"
"use client";

import { useState } from "react"
import { mockGroup } from "@/entities/group/model/mock-data"
import { MemberCard } from "@/entities/member/ui/member-card"
import { Modal } from "@/shared/ui/modal"
import { Copy, Check, Link2 } from "lucide-react"
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import type { GroupMemberDto } from "@/entities/group/model/api/types";
import type { Member } from "@/entities/group/model/types";
import { useGroupDetailQuery } from "@/entities/group/queries/useGroupDetailQuery";
import { useGroupMembersQuery } from "@/entities/group/queries/useGroupMembersQuery";
import { MemberCard } from "@/entities/member/ui/member-card";
import {
type MemberActionType,
type PendingMemberAction,
} from "@/features/group/members/model/memberManageAction";
import { useGroupMemberManageAction } from "@/features/group/members/model/useGroupMemberManageAction";
import { MemberManageActionButtons } from "@/features/group/members/ui/actions/MemberManageActionButtons";
import { InviteLinkModal } from "@/features/group/members/ui/modals/InviteLinkModal";
import { MemberActionConfirmModal } from "@/features/group/members/ui/modals/MemberActionConfirmModal";
import { GroupDetailQueryErrorState } from "@/widgets/group/detail/ui/GroupDetailQueryErrorState";

const mapGroupMemberToMember = (member: GroupMemberDto): Member => ({
id: member.id,
name: member.name,
avatar: member.profileUrl || undefined,
bio: member.description || "소개가 없습니다.",
isLeader: member.isOwner,
});

export default function MembersPage() {
const group = mockGroup
const [showModal, setShowModal] = useState(false)
const [copied, setCopied] = useState(false)
const params = useParams<{ id: string }>();
const routeGroupId = Array.isArray(params?.id) ? params.id[0] : params?.id;
const { data: groupDetail, isError: isDetailError, isPending: isDetailPending, refetch: refetchDetail } =
useGroupDetailQuery(routeGroupId);
const { data: groupMembers, isError: isMembersError, isPending: isMembersPending, refetch: refetchMembers } =
useGroupMembersQuery(routeGroupId);
const { processingMemberId, requestKickMember, requestTransferOwner } =
useGroupMemberManageAction(routeGroupId ?? "");

const inviteLink = `https://triple.app/invite/${group.id}?code=aBcD1234`
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [pendingMemberAction, setPendingMemberAction] = useState<PendingMemberAction | null>(null);

const closeModal = () => {
setShowModal(false)
setCopied(false)
}
const inviteLink = `https://triple.app/invite/${routeGroupId ?? ""}?code=aBcD1234`;
const members = groupMembers?.users ?? [];
const isOwner = groupDetail?.role?.toUpperCase() === "OWNER";
const isLoading = isDetailPending || isMembersPending;
const isError = isDetailError || isMembersError;
const isProcessingMemberAction =
pendingMemberAction !== null && processingMemberId === pendingMemberAction.member.id;

const mappedMembers = useMemo(
() => members.map((groupMember) => ({ groupMember, member: mapGroupMemberToMember(groupMember) })),
[members]
);

const handleRetry = () => {
void refetchDetail();
void refetchMembers();
};

const handleOpenMemberActionModal = (
actionType: MemberActionType,
targetMember: GroupMemberDto
) => {
setPendingMemberAction({
type: actionType,
member: targetMember,
});
};

const handleCloseMemberActionModal = () => {
if (isProcessingMemberAction) {
return;
}

setPendingMemberAction(null);
};

const handleConfirmMemberAction = async () => {
if (!pendingMemberAction) {
return;
}

const actionSuccess =
pendingMemberAction.type === "kick"
? await requestKickMember(pendingMemberAction.member.id)
: await requestTransferOwner(pendingMemberAction.member.id);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
const textarea = document.createElement("textarea")
textarea.value = inviteLink
document.body.appendChild(textarea)
textarea.select()
document.execCommand("copy")
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
if (actionSuccess) {
setPendingMemberAction(null);
}
};

if (!routeGroupId) {
return null;
}

if (isError) {
return <GroupDetailQueryErrorState onRetry={handleRetry} />;
}

if (isLoading) {
return (
<div className="py-10 text-sm text-muted-foreground">
멤버 목록을 불러오는 중입니다.
</div>
);
}

return (
<div>
<h1 className="text-xl font-bold text-foreground">
{"멤버"}{" "}
<span className="text-primary">{group.currentMemberCount}</span>
멤버 <span className="text-primary">{members.length}</span>
</h1>

<div className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2">
{group.members.map((member) => (
<MemberCard key={member.id} member={member} />
{mappedMembers.map(({ groupMember, member }) => (
<MemberCard
key={groupMember.id}
member={member}
action={
isOwner && !groupMember.isOwner ? (
<MemberManageActionButtons
member={groupMember}
disabled={Boolean(processingMemberId)}
onTransferOwner={(targetMember) =>
handleOpenMemberActionModal("transfer", targetMember)
}
onKick={(targetMember) => handleOpenMemberActionModal("kick", targetMember)}
/>
) : null
}
/>
))}
</div>

<div className="mt-8 flex justify-end">
<button
type="button"
onClick={() => setShowModal(true)}
onClick={() => setIsInviteModalOpen(true)}
className="rounded-full bg-primary px-8 py-3 text-sm font-bold text-primary-foreground transition-opacity hover:opacity-90"
>
{"초대 링크"}
초대 링크
</button>
</div>

<Modal
open={showModal}
onClose={closeModal}
title={"초대 링크"}
description={"아래 링크를 공유해 그룹에 멤버를 초대하세요."}
>
<div className="mt-5 flex items-center gap-2 rounded-lg bg-muted px-4 py-3">
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm text-foreground">{inviteLink}</span>
</div>
<InviteLinkModal
open={isInviteModalOpen}
onClose={() => setIsInviteModalOpen(false)}
inviteLink={inviteLink}
/>

<button
type="button"
onClick={handleCopy}
className={`mt-5 flex w-full items-center justify-center gap-2 rounded-full py-3 text-sm font-bold transition-all ${
copied
? "bg-green-500 text-background"
: "bg-primary text-primary-foreground hover:opacity-90"
}`}
>
{copied ? (
<>
<Check className="h-4 w-4" />
{"복사 완료!"}
</>
) : (
<>
<Copy className="h-4 w-4" />
{"링크 복사하기"}
</>
)}
</button>
</Modal>
<MemberActionConfirmModal
pendingAction={pendingMemberAction}
isProcessing={isProcessingMemberAction}
onClose={handleCloseMemberActionModal}
onConfirm={() => void handleConfirmMemberAction()}
/>
</div>
)
);
}
2 changes: 1 addition & 1 deletion app/group/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import { useParams } from "next/navigation"
import { useGroupDetailQuery } from "@/entities/group/queries/group.queries"
import { useGroupDetailQuery } from "@/entities/group/queries/useGroupDetailQuery"
import { GroupDetailReviewPhotosSection } from "@/widgets/group/detail/ui/GroupDetailReviewPhotosSection"
import { GroupDetailMembersSection } from "@/widgets/group/detail/ui/GroupDetailMembersSection"
import { GroupDetailSchedulesSection } from "@/widgets/group/detail/ui/GroupDetailSchedulesSection"
Expand Down
17 changes: 9 additions & 8 deletions app/kakao/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { toast } from "@/shared/hooks/use-toast";
import { kakaoOauthLogin } from "@/features/auth/api/kakaoOauthLogin";
import { consumeOAuthReturnPath } from "@/shared/lib/auth-return-path";
import { TOAST_MESSAGES } from "@/shared/constants/toast";
import { showErrorToast } from "@/shared/lib/error-toast";

function KakaoCallbackInner() {
const searchParams = useSearchParams();
Expand All @@ -25,8 +26,8 @@ function KakaoCallbackInner() {
if (!code) {
toast({
variant: "destructive",
title: TOAST_MESSAGES.LOGIN_FAILURE.title,
description: TOAST_MESSAGES.LOGIN_FAILURE.description,
title: TOAST_MESSAGES.AUTH.LOGIN_FAILURE.title,
description: TOAST_MESSAGES.AUTH.LOGIN_FAILURE.description,
});
replaceToReturnPath();
return;
Expand All @@ -36,15 +37,15 @@ function KakaoCallbackInner() {
try {
await kakaoOauthLogin(code);
toast({
title: TOAST_MESSAGES.LOGIN_SUCCESS.title,
description: TOAST_MESSAGES.LOGIN_SUCCESS.description,
title: TOAST_MESSAGES.AUTH.LOGIN_SUCCESS.title,
description: TOAST_MESSAGES.AUTH.LOGIN_SUCCESS.description,
});
} catch (error) {
if (!isAxiosError(error) || error.response?.status !== 401) {
toast({
variant: "destructive",
title: TOAST_MESSAGES.LOGIN_FAILURE.title,
description: TOAST_MESSAGES.LOGIN_FAILURE.description,
showErrorToast({
error,
title: TOAST_MESSAGES.AUTH.LOGIN_FAILURE.title,
fallbackDescription: TOAST_MESSAGES.AUTH.LOGIN_FAILURE.description,
});
}
} finally {
Expand Down
19 changes: 19 additions & 0 deletions entities/group/model/api/approveGroupJoinApply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { REQUEST_PATHS } from "@/shared/constants/paths";
import api from "@/shared/lib/api/client";

export const approveGroupJoinApply = async (
groupId: number,
joinApplyId: number
): Promise<void> => {
const requestBody = new URLSearchParams();

await api.post(
REQUEST_PATHS.GROUPS.APPROVE_JOIN_APPLY(groupId, joinApplyId),
requestBody,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
};
Loading