Skip to content
Open
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
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
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,30 @@
"use client";

import { SettingsProfileForm } from "@/app/[locale]/(main)/settings/_components/SettingsProfileForm";
import { useSettingsProfile } from "@/app/[locale]/(main)/settings/_hooks/useSettingsProfile";
import { useSettingsProfileLabels } from "@/app/[locale]/(main)/settings/_hooks/useSettingsProfileLabels";

export const SettingsProfileContainer = () => {
const { profileState, profileActions } = useSettingsProfile();
const labels = useSettingsProfileLabels();

return (
<div className="px-15 pt-7.5">
<SettingsProfileForm
name={profileState.name}
googleEmail={profileState.googleEmail}
isCalendarConnected={profileState.isCalendarConnected}
language={profileState.language}
tags={profileState.tags}
isSaveDisabled={profileState.isSaveDisabled}
labels={labels}
onConnectCalendar={profileActions.onConnectCalendar}
onChangeLanguage={profileActions.onChangeLanguage}
onAddTag={profileActions.onAddTag}
onRemoveTag={profileActions.onRemoveTag}
onLogout={profileActions.onLogout}
onSave={profileActions.onSave}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

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

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

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 useSettingsProfile = () => {
const tCommon = useTranslations("Common");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();

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 handleConnectCalendar = () => {
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 }));
};

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

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

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 {
profileState: {
name: profile.name,
googleEmail: profile.googleEmail,
isCalendarConnected: profile.isCalendarConnected,
language,
tags: tagItems,
isSaveDisabled: !formState.isDirty || formState.isSubmitting,
},
profileActions: {
onConnectCalendar: handleConnectCalendar,
onChangeLanguage: handleChangeLanguage,
onAddTag: handleAddTag,
onRemoveTag: handleRemoveTag,
onLogout: handleLogout,
onSave: handleSave,
},
};
};
Loading
Loading