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
Binary file removed public/image/default_team_img.png
Binary file not shown.
68 changes: 68 additions & 0 deletions src/app/(team)/_components/JoinTeamForm/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import InputBase from '@/components/common/Input/InputBase';
import Button from '@/components/common/Button';

export interface JoinTeamFormProps {
link: string;
onLinkChange: (v: string) => void;
onSubmit: () => void;
error?: string;
isLoading?: boolean;
}

export default function JoinTeamForm({
link,
onLinkChange,
onSubmit,
error,
isLoading,
}: JoinTeamFormProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
onSubmit();
}
};

return (
<div className="text-md-regular tablet:w-[460px] tablet:text-lg-regular flex w-[343px] flex-col items-center">
<h1 className="text-2xl-medium laptop:text-4xl-medium tablet:mb-20 mb-6">
팀 참여하기
</h1>

<div className="mb-10 w-full self-start">
<InputBase
id="teamLink"
title="팀 링크"
placeholder="팀 링크를 입력해주세요."
value={link}
isInvalid={Boolean(error)}
onChange={(e) => onLinkChange(e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="off"
titleClassName="mb-3"
containerClassName="h-11 tablet:h-12 bg-slate-800"
inputClassName="w-full h-11 tablet:h-12"
/>
{error && <p className="text-md-medium mt-2 text-red-500">{error}</p>}
</div>

<div className="mb-6 w-full">
<Button
variant="primary"
styleType="filled"
size="lg"
radius="sm"
className="text-lg-semibold w-full"
onClick={onSubmit}
disabled={!link.trim() || isLoading}
>
{isLoading ? '처리 중...' : '참여하기'}
</Button>
</div>

<p className="text-md-regular tablet:text-lg-regular">
공유받은 팀 링크를 입력해 참여할 수 있어요.
</p>
</div>
);
}
74 changes: 61 additions & 13 deletions src/app/(team)/_components/TeamProfileForm/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client';

import { useEffect, useState } from 'react';
import InputBase from '@/components/common/Input/InputBase';
import Button from '@/components/common/Button';
Expand All @@ -11,9 +9,15 @@ export interface TeamProfileFormProps {
existingNames: string[];
initialPreview?: string;
submitLabel: string;
onSubmit: (data: { name: string; file?: File }) => Promise<void> | void;
onSubmit: (data: {
name: string;
file?: File;
removeImage?: boolean;
}) => Promise<void> | void;
}

const MAX_FILE_SIZE = 4.2 * 1024 * 1024;

export default function TeamProfileForm({
initialName = '',
existingNames,
Expand All @@ -25,17 +29,36 @@ export default function TeamProfileForm({
const [preview, setPreview] = useState(initialPreview);
const [file, setFile] = useState<File>();
const [nameError, setNameError] = useState(false);
const [imageError, setImageError] = useState('');

useEffect(() => {
setName(initialName);
setPreview(initialPreview);
setFile(undefined);
setNameError(false);
setImageError('');
}, [initialName, initialPreview]);
Comment on lines 34 to 40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useEffectprops 로 전달되는 initialName 이 변경되면, 내부 file 상태도 초기화시킨 거군요~!
이전에 업로드한 파일 정보도 함께 초기화시킬 수 있어서 좋은 방법이네요 👍


const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (!selected) return;
setFile(selected);

setPreview(URL.createObjectURL(selected));

if (selected.size > MAX_FILE_SIZE) {
setImageError('4.2MB 이하의 이미지만 업로드할 수 있습니다.');
setFile(undefined);
return;
}

setImageError('');
setFile(selected);
};

const handleClearImage = () => {
setImageError('');
setFile(undefined);
setPreview('');
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
Expand All @@ -45,19 +68,27 @@ export default function TeamProfileForm({
}
};

const removeImage = preview === '' && !file;

const handleSubmit = async () => {
let hasErr = false;

if (existingNames.includes(name.trim())) {
setNameError(true);
hasErr = true;
}

if (imageError) {
hasErr = true;
}
if (hasErr) return;
await onSubmit({ name: name.trim(), file });

await onSubmit({ name: name.trim(), file, removeImage });
};

const isDisabled = !name.trim() || Boolean(imageError);

return (
<div className="text-md-regular tablet:w-[456px] tablet:h-[460px] tablet:text-lg-regular flex h-[374px] w-[343px] flex-col items-center">
<div className="text-md-regular tablet:w-[460px] tablet:text-lg-regular flex w-[343px] flex-col items-center">
<h1 className="text-2xl-medium laptop:text-4xl-medium tablet:mb-20 mb-6">
{submitLabel}
</h1>
Expand All @@ -79,20 +110,35 @@ export default function TeamProfileForm({
</label>
<label
htmlFor="team-profile-input"
className="absolute bottom-0 left-11 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border-2 border-slate-900 bg-slate-600"
className="absolute top-19 left-11 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full border-2 border-slate-900 bg-slate-600"
>
<IconRenderer name="EditIcon" size={9} />
</label>

{preview && (
<button
type="button"
onClick={handleClearImage}
className="absolute top-0 right-0 flex h-5 w-5 items-center justify-center rounded-full bg-gray-500 hover:bg-gray-100"
>
<IconRenderer name="XIcon" size={12} />
</button>
)}

<input
id="team-profile-input"
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>

{imageError && (
<p className="mt-1 text-xs text-red-500">{imageError}</p>
)}
</div>
<div className="mb-6 w-full self-start">

<div className="mb-10 w-full self-start">
<InputBase
id="teamName"
title="팀 이름"
Expand All @@ -105,8 +151,8 @@ export default function TeamProfileForm({
setNameError(false);
}}
onKeyDown={handleKeyDown}
titleClassName="mb-6"
containerClassName={`w-full bg-slate-800${nameError ? ' border border-red-500' : ''}`}
titleClassName="mb-3"
containerClassName=" h-11 tablet:h-12 bg-slate-800"
inputClassName="w-full h-11 tablet:h-12"
/>
{nameError && (
Expand All @@ -115,19 +161,21 @@ export default function TeamProfileForm({
</p>
)}
</div>
<div className="mb-4 w-full">

<div className="mb-6 w-full">
<Button
variant="primary"
styleType="filled"
size="lg"
radius="sm"
className="text-lg-semibold w-full"
onClick={handleSubmit}
disabled={!name.trim()}
disabled={isDisabled}
>
{submitLabel}
</Button>
</div>

<p className="text-md-regular tablet:text-lg-regular">
팀 이름은 회사명이나 모임 이름 등으로 설정하면 좋아요.
</p>
Expand Down
65 changes: 64 additions & 1 deletion src/app/(team)/join-team/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,66 @@
'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ROUTES } from '@/constants/routes';
import JoinTeamForm from '@/app/(team)/_components/JoinTeamForm';
import { postGroupInvitation } from '@/lib/apis/group';
import { useMemberships } from '@/hooks/useMemberships';
import { INVITATION_ERROR_MAP } from '@/utils/errorMap';

const jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;

export default function JoinTeamPage() {
return <div>JoinTeamPage</div>;
const router = useRouter();
const { memberships } = useMemberships(true);

const [link, setLink] = useState('');
const [error, setError] = useState<string>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error 초깃값을 undefined 로 보시고 일부러 비워두신 건지 궁금합니다~!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined 를 에러가 없는 상태로 구분하려고 했습니다!

const [isLoading, setLoading] = useState(false);

useEffect(() => {
setError(undefined);
}, [link]);

Comment on lines +21 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유저가 링크를 수정할 경우, 이전 에러 메시지가 남아있지 않도록 기존의 에러 메시지를 삭제하는 패턴이 맞을까요?

해당 방식은 만약 사용자가 이전과 동일한, 잘못된 값을 다시 입력하더라도 에러 메시지가 사라졌다가 다시 나타나는, 불필요한 상태 업데이트가 발생할 수 있습니다. 입력값이 바뀌어도 기존 에러 메시지와 동일한 문제가 반복된다면, setError(undefined) 를 하지 않고 에러 메시지를 그대로 두는 방식도 추후 고려해보시면 좋을 것 같습니다 !

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자가 입력칸에 변화를 주면 이전의 에러메세지는 사라지는 것이 자연스럽다고 생각했습니다.
말씀주신 부분도 고려해보겠습니다!

const handleJoin = async () => {
if (!jwtRegex.test(link.trim())) {
setError('유효한 링크가 아닙니다.');
return;
}
const email = memberships[0]?.userEmail;
if (!email) {
setError('사용자 정보를 불러올 수 없습니다.');
return;
}

setLoading(true);
try {
const res = await postGroupInvitation({
body: { token: link.trim(), userEmail: email },
});
if (res?.groupId == null) {
throw new Error(res?.message || '초대 수락 실패');
}
router.push(ROUTES.TEAM(res.groupId));
} catch (err: unknown) {
const raw = err instanceof Error ? err.message : String(err);
setError(
INVITATION_ERROR_MAP[raw] ?? '초대 수락 중 오류가 발생했습니다.'
);
} finally {
setLoading(false);
}
};

return (
<div className="flex w-full justify-center pt-35">
<JoinTeamForm
link={link}
onLinkChange={setLink}
onSubmit={handleJoin}
error={error}
isLoading={isLoading}
/>
</div>
);
}
19 changes: 16 additions & 3 deletions src/app/(team)/team/[teamid]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
useGroup,
useUpdateGroup,
} from '@/hooks/useGroupQueries';
import Spinner from '@/components/common/Loading/Spinner';

export default function EditTeamPage() {
const router = useRouter();
Expand All @@ -22,7 +23,11 @@ export default function EditTeamPage() {
const { openModal } = useModalStore();

if (groupQuery.isLoading) {
return <p>팀 정보를 불러오는 중...</p>;
return (
<div className="mt-30 flex h-full w-full items-center justify-center">
<Spinner size={48} />
</div>
);
}

if (groupQuery.isError || !groupQuery.data) {
Expand All @@ -31,8 +36,16 @@ export default function EditTeamPage() {

const group = groupQuery.data;

const handleEdit = async ({ name, file }: { name: string; file?: File }) => {
await updateGroup.mutateAsync({ name, file });
const handleEdit = async ({
name,
file,
removeImage,
}: {
name: string;
file?: File;
removeImage?: boolean;
}) => {
await updateGroup.mutateAsync({ name, file, removeImage });
router.replace(ROUTES.TEAM(id));
};

Expand Down
20 changes: 12 additions & 8 deletions src/components/common/Header/TeamMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,18 @@ export default function TeamMenu({
}}
>
<div className="relative h-8 w-8">
<Image
src={group.image ?? '/image/default_team_img.png'}
alt={group.name}
fill
// 임시조치 - 나중에 도메인 추가 예정
unoptimized
className="rounded-sm object-cover"
/>
{group.image ? (
<Image
src={`${group.image}`}
alt={group.name}
fill
// 임시조치 - 나중에 도메인 추가 예정
unoptimized
className="rounded-sm object-cover"
/>
) : (
<IconRenderer name="ImgIcon" size={32} />
)}
</div>
<span className="text-sm whitespace-nowrap">
{group.name}
Expand Down
12 changes: 8 additions & 4 deletions src/hooks/useGroupQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,19 @@ export function useUpdateGroup(teamId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (
data: GroupBody & { file?: File }
data: GroupBody & { file?: File; removeImage?: boolean }
): Promise<GroupResponse | null> => {
let imageUrl: string | undefined;
const body: { name: string; image?: string | null } = { name: data.name };

if (data.file) {
imageUrl = await uploadImage(data.file);
const url = await uploadImage(data.file);
body.image = url;
} else if (data.removeImage) {
body.image = null;
}
return patchGroupById({
groupId: teamId,
body: { name: data.name, ...(imageUrl ? { image: imageUrl } : {}) },
body,
Comment on lines +48 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uploadImage 함수로 이미지 URL 을 받은 후, 필요한 데이터를 한꺼번에 body 객체에 모아 요청을 보내는 방식 깔끔하네요 👍

});
},
onSuccess: () => {
Expand Down
4 changes: 4 additions & 0 deletions src/utils/errorMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const INVITATION_ERROR_MAP: Record<string, string> = {
'이미 그룹에 소속된 유저입니다.': '이미 참여 중인 팀입니다',
'유효하지 않은 초대입니다.': '유효한 링크가 아닙니다.',
};