Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0448956
feat(web): 설정 페이지 공용 네비와 서브페이지 뼈대를 구현한다 (#122)
jjangminii Jul 8, 2026
2cfa22d
feat(web): 설정 개인정보 폼을 구현한다 (#122)
jjangminii Jul 8, 2026
d22ee51
fix(web): 설정 사이드바가 콘텐츠와 함께 스크롤되지 않도록 고정한다 (#122)
jjangminii Jul 8, 2026
6dc08a0
feat(ui): TagButton 공통 컴포넌트를 추가한다 (#122)
jjangminii Jul 8, 2026
f479b60
feat(web): 설정 개인정보 폼에 캘린더 연동·태그 추가·저장 로직을 구현한다 (#122)
jjangminii Jul 8, 2026
0637127
feat(web): 설정 개인정보 화면에 다국어 지원을 적용하고 레이아웃을 다듬는다 (#122)
jjangminii Jul 8, 2026
4949da1
fix(web): 언어 토글이 저장 전까지 화면에 즉시 반영되지 않도록 수정한다 (#122)
jjangminii Jul 8, 2026
d66f953
feat(web): 회원 탈퇴 화면을 구현한다 (#122)
jjangminii Jul 8, 2026
87b91b6
chore(web): react-hook-form 의존성을 추가한다 (#122)
jjangminii Jul 10, 2026
5d1d873
refactor(web): 설정 프로필 컴포넌트에 React Hook Form을 도입해 상태 관리를 단순화한다 (#122)
jjangminii Jul 10, 2026
d5b1c4a
refactor(ui): 사용하지 않는 TagButtonVariant 타입 export를 제거한다 (#122)
jjangminii Jul 10, 2026
a8d06b9
fix(web): 설정 레이아웃의 중복 main 랜드마크를 section으로 수정한다 (#122)
jjangminii Jul 10, 2026
1705563
style(web): 설정 프로필 컨테이너 훅 선언부 개행을 정리한다 (#122)
jjangminii Jul 10, 2026
12fc4b8
refactor(ui): TagButton을 TagChip·PillButton으로 용도별로 분리한다 (#122)
jjangminii Jul 10, 2026
7b29f68
refactor(web): 설정 프로필 폼이 TagChip·PillButton을 사용하도록 수정한다 (#122)
jjangminii Jul 10, 2026
649e1ee
fix(web): 탈퇴하기 버튼이 부모 너비로 늘어나지 않고 hug 되도록 수정한다 (#122)
jjangminii Jul 10, 2026
5a79e43
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
jjangminii Jul 10, 2026
7c202f7
refactor(web): 설정 프로필 컨테이너 로직을 커스텀 훅으로 분리한다 (#122)
jjangminii Jul 11, 2026
178ba4d
refactor(web): 설정 언어 전환을 즉시 반영 + URL 쿼리 파라미터 방식으로 변경 (#122)
jjangminii Jul 12, 2026
58f53e8
refactor(web): 설정 하위 페이지 라우팅을 쿼리 파라미터 탭 방식으로 변경 (#122)
jjangminii Jul 12, 2026
3a8816d
fix(web): 언어 토글이 저장 전까지 화면에 즉시 반영되지 않도록 수정 (#122)
jjangminii Jul 12, 2026
a63f37b
Merge branch 'develop' of https://github.com/Team-Timo/Timo-client in…
jjangminii Jul 12, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { PlusIcon } from "@repo/timo-design-system/icons";
import {
CreateButton,
PillButton,
TagChip,
TogglePanel,
} from "@repo/timo-design-system/ui";
import Image from "next/image";

import type {
SettingsLanguage,
SettingsProfileLabels,
SettingsTagItem,
} from "@/app/[locale]/(main)/settings/_types/profile-type";

export interface SettingsProfileFormProps {
name: string;
googleEmail: string;
isCalendarConnected: boolean;
language: SettingsLanguage;
tags: SettingsTagItem[];
isSaveDisabled: boolean;
labels: SettingsProfileLabels;
onConnectCalendar: () => void;
onChangeLanguage: (language: SettingsLanguage) => void;
onAddTag: () => void;
onRemoveTag: (tag: string) => void;
onLogout: () => void;
onSave: () => void;
}

export const SettingsProfileForm = ({
name,
googleEmail,
isCalendarConnected,
language,
tags,
isSaveDisabled,
labels,
onConnectCalendar,
onChangeLanguage,
onAddTag,
onRemoveTag,
onLogout,
onSave,
}: SettingsProfileFormProps) => {
return (
<div className="flex flex-col items-end gap-7.5 pb-15">
<div className="flex w-full flex-col gap-7.5">
<h1 className="typo-headline-m-16 text-timo-gray-900">
{labels.title}
</h1>
<hr className="border-timo-gray-500" />

<section className="flex flex-col gap-4">
<h2 className="typo-headline-b-16 text-timo-gray-900">
{labels.profileSection}
</h2>
<div className="flex items-center gap-5">
<div className="bg-timo-gray-300 size-28.5 shrink-0 rounded-full" />
<div className="flex flex-col gap-2">
<p className="typo-headline-b-20 text-timo-black">{name}</p>
<div className="bg-timo-gray-300 flex items-center gap-2 rounded-lg px-4 py-2">
<Image
src="/images/google-logo.png"
alt="Google"
width={20}
height={20}
/>
<span className="typo-headline-m-16 text-timo-gray-900">
{googleEmail}
</span>
</div>
</div>
</div>
</section>

<hr className="border-timo-gray-500" />

<section className="flex flex-col gap-3">
<h2 className="typo-headline-b-16 text-timo-gray-900">
{labels.calendarSection}
</h2>
<div className="bg-timo-gray-300 flex h-10.25 w-fit items-center gap-4 self-start rounded-lg px-2.5 py-1.5">
<div className="flex items-center gap-1.5">
<Image
src="/images/google-calendar.png"
alt=""
width={20}
height={20}
/>
<span className="typo-headline-m-16 text-timo-gray-700 whitespace-nowrap">
Google Calendar
</span>
</div>

<PillButton variant="blue" onClick={onConnectCalendar}>
{isCalendarConnected ? labels.disconnect : labels.connect}
</PillButton>
</div>
</section>

<hr className="border-timo-gray-500" />

<section className="flex w-67 flex-col gap-3">
<h2 className="typo-headline-b-16 text-timo-gray-900">
{labels.languageSection}
</h2>
<TogglePanel
id="settings-language"
value={language}
onChange={(value) => onChangeLanguage(value as SettingsLanguage)}
options={[
{ value: "ko", label: labels.languageKorean },
{ value: "en", label: labels.languageEnglish },
]}
/>
</section>

<hr className="border-timo-gray-500" />

<section className="flex w-full flex-col gap-3">
<h2 className="typo-headline-b-16 text-timo-gray-900">
{labels.tagsSection}
</h2>
<div className="flex flex-wrap items-center gap-3">
{tags.map((tag) => (
<TagChip
key={tag.id}
onRemove={tag.isDefault ? undefined : () => onRemoveTag(tag.id)}
removeLabel={labels.removeTag(tag.label)}
>
{tag.label}
</TagChip>
))}

<PillButton
icon={<PlusIcon width={20} height={20} />}
onClick={onAddTag}
>
{labels.addTag}
</PillButton>
</div>
</section>

<hr className="border-timo-gray-500" />

<PillButton onClick={onLogout} className="w-fit">
{labels.logout}
</PillButton>
</div>

<CreateButton
label={labels.save}
disabled={isSaveDisabled}
onClick={onSave}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { cn } from "@repo/timo-design-system/utils";
import { useTranslations } from "next-intl";

import { ROUTES } from "@/constants/routes";
import { Link, usePathname } from "@/i18n/navigation";

export const SettingsNavContainer = () => {
const t = useTranslations("Navigation");
const tSettings = useTranslations("Settings");
const pathname = usePathname();

const settingsNavItems = [
{ label: tSettings("nav.account"), href: ROUTES.SETTINGS },
{ label: tSettings("nav.policy"), href: ROUTES.SETTINGS_POLICY },
{ label: tSettings("nav.withdrawal"), href: ROUTES.SETTINGS_WITHDRAWAL },
];

return (
<nav className="border-timo-gray-500 flex w-37.5 shrink-0 flex-col gap-3 border-r bg-white px-2.5 pt-3.25">
<Link
href={ROUTES.SETTINGS}
className="typo-headline-m-16 text-timo-gray-900 block px-2.5 py-1.5"
>
{t("settings")}
</Link>

<ul className="flex flex-col gap-2">
{settingsNavItems.map((item) => {
const isActive = pathname === item.href;

return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"typo-headline-m-14 block rounded-[4px] px-2.5 py-1",
isActive
? "bg-timo-blue-65 text-timo-blue-300"
: "text-timo-gray-900",
)}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"use client";

import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";

import type {
SettingsDefaultTagKey,
SettingsLanguage,
SettingsProfile,
SettingsProfileFormValues,
SettingsProfileLabels,
} from "@/app/[locale]/(main)/settings/_types/profile-type";

import { SettingsProfileForm } from "@/app/[locale]/(main)/settings/_components/SettingsProfileForm";
import { settingsProfileMock } from "@/app/[locale]/(main)/settings/_mocks/profile-mock";
import { usePathname, useRouter } from "@/i18n/navigation";

const DEFAULT_TAG_KEYS: SettingsDefaultTagKey[] = [
"assignment",
"work",
"exercise",
"dailyLife",
];

const isDefaultTagKey = (tag: string): tag is SettingsDefaultTagKey =>
DEFAULT_TAG_KEYS.includes(tag as SettingsDefaultTagKey);

export const SettingsProfileContainer = () => {
const tCommon = useTranslations("Common");
const tSettings = useTranslations("Settings");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
Comment thread
jjangminii marked this conversation as resolved.
Resolved

const [profile, setProfile] = useState<SettingsProfile>(settingsProfileMock);

const { watch, setValue, handleSubmit, reset, formState } =
useForm<SettingsProfileFormValues>({
defaultValues: { language: locale, tags: profile.tags },
});
const language = watch("language");
const tags = watch("tags");

const tagItems = tags.map((tag) => {
const isDefault = isDefaultTagKey(tag);

return {
id: tag,
label: isDefault ? tCommon(`tag.${tag}`) : tag,
isDefault,
};
});

const labels: SettingsProfileLabels = {
title: tSettings("profile.title"),
profileSection: tSettings("profile.profileSection"),
calendarSection: tSettings("profile.calendarSection"),
connect: tSettings("profile.connect"),
disconnect: tSettings("profile.disconnect"),
languageSection: tSettings("profile.languageSection"),
languageKorean: tSettings("profile.languageKorean"),
languageEnglish: tSettings("profile.languageEnglish"),
tagsSection: tSettings("profile.tagsSection"),
addTag: tSettings("profile.addTag"),
logout: tSettings("profile.logout"),
save: tSettings("profile.save"),
removeTag: (tag: string) => tSettings("profile.removeTag", { tag }),
};
Comment thread
jjangminii marked this conversation as resolved.
Resolved

const handleConnectCalendar = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

handleConnectCalendar가 실제로는 연동과 연동 해제를 모두 처리하고 있어서, handleToggleCalendarConnection처럼 연동/해제를 모두 포함하는 동작이 드러나는 이름도 고려해볼 수 있을 것 같습니다! 아니면 함수명이 너무 길어져서 안 좋을까요?

if (profile.isCalendarConnected) {
// TODO: 실제 확인 모달로 교체
const confirmed = window.confirm("구글 캘린더 연동을 해제하시겠습니까?");
if (!confirmed) return;

// TODO: API - 연동 토큰 파기
console.log("구글 캘린더 연동 토큰을 파기합니다.");
setProfile((prev) => ({ ...prev, isCalendarConnected: false }));
return;
}

// TODO: Google 계정 인증 및 캘린더 접근 권한 동의 팝업 호출
console.log("Google Calendar 연동 인증 팝업을 호출합니다.");
setProfile((prev) => ({ ...prev, isCalendarConnected: true }));
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const handleChangeLanguage = (nextLanguage: SettingsLanguage) => {
setValue("language", nextLanguage, { shouldDirty: true });
};

const handleAddTag = () => {
// TODO: 실제 태그 추가 모달로 교체
const newTag = window.prompt("추가할 태그를 입력해주세요")?.trim();
if (!newTag || tags.includes(newTag)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

화면설계서에 "1~10자의 중복 없는 신규 태그명 입력 시 활성화 상태로 클릭 가능" 이라는 조건이 있는데 현재는 중복 검사만 있고 길이 제한 로직이 없는 것 같습니다 확인 부탁드릴게용🙇‍♀️

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

모달 추가하면서 로직 같이 추가하려고합니다-!


setValue("tags", [...tags, newTag], { shouldDirty: true });
};

const handleRemoveTag = (tag: string) => {
setValue(
"tags",
tags.filter((existingTag) => existingTag !== tag),
{ shouldDirty: true },
);
};

const handleLogout = () => {
// TODO: API - 로그아웃 처리 및 브라우저 저장 데이터 파기
console.log("로그아웃 API 호출");
// TODO: 로그인 페이지 라우트 추가 후 이동 연결 (뒤로가기로 재접근 차단 포함)
console.log("[Login] 페이지로 이동합니다.");
};

const handleSave = handleSubmit(async (values) => {
try {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 1000));
reset(values);

if (values.language !== locale) {
router.replace(pathname, { locale: values.language });
}
} catch {
// TODO: 실제 토스트 컴포넌트로 교체
window.alert("저장에 실패했습니다. 잠시 후 다시 시도해 주세요.");
}
});

return (
<div className="px-15 pt-7.5">
<SettingsProfileForm
name={profile.name}
googleEmail={profile.googleEmail}
isCalendarConnected={profile.isCalendarConnected}
language={language}
tags={tagItems}
isSaveDisabled={!formState.isDirty || formState.isSubmitting}
labels={labels}
onConnectCalendar={handleConnectCalendar}
onChangeLanguage={handleChangeLanguage}
onAddTag={handleAddTag}
onRemoveTag={handleRemoveTag}
onLogout={handleLogout}
onSave={handleSave}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { SettingsProfile } from "@/app/[locale]/(main)/settings/_types/profile-type";

export const settingsProfileMock: SettingsProfile = {
name: "김도연",
googleEmail: "timogoogle@gmail.com",
isCalendarConnected: false,
tags: ["assignment", "work", "exercise", "dailyLife", "안녕"],
};
Loading
Loading