diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/SettingsProfileForm.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/SettingsProfileForm.tsx
new file mode 100644
index 00000000..998defac
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_components/SettingsProfileForm.tsx
@@ -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 (
+
+
+
+ {labels.title}
+
+
+
+
+
+ {labels.profileSection}
+
+
+
+
+
{name}
+
+
+
+ {googleEmail}
+
+
+
+
+
+
+
+
+
+
+ {labels.calendarSection}
+
+
+
+
+
+ Google Calendar
+
+
+
+
+ {isCalendarConnected ? labels.disconnect : labels.connect}
+
+
+
+
+
+
+
+
+ {labels.languageSection}
+
+ onChangeLanguage(value as SettingsLanguage)}
+ options={[
+ { value: "ko", label: labels.languageKorean },
+ { value: "en", label: labels.languageEnglish },
+ ]}
+ />
+
+
+
+
+
+
+ {labels.tagsSection}
+
+
+ {tags.map((tag) => (
+
onRemoveTag(tag.id)}
+ removeLabel={labels.removeTag(tag.label)}
+ >
+ {tag.label}
+
+ ))}
+
+
}
+ onClick={onAddTag}
+ >
+ {labels.addTag}
+
+
+
+
+
+
+
+ {labels.logout}
+
+
+
+
+
+ );
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/SettingsNavContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/SettingsNavContainer.tsx
new file mode 100644
index 00000000..7b63d7c4
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/SettingsNavContainer.tsx
@@ -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 (
+
+ );
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/SettingsProfileContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/SettingsProfileContainer.tsx
new file mode 100644
index 00000000..4fba9d48
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/SettingsProfileContainer.tsx
@@ -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 (
+
+
+
+ );
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts
new file mode 100644
index 00000000..5abf5546
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfile.ts
@@ -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(settingsProfileMock);
+
+ const { watch, setValue, handleSubmit, reset, formState } =
+ useForm({
+ 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,
+ },
+ };
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfileLabels.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfileLabels.ts
new file mode 100644
index 00000000..de96066d
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/useSettingsProfileLabels.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+
+import type { SettingsProfileLabels } from "@/app/[locale]/(main)/settings/_types/profile-type";
+
+export const useSettingsProfileLabels = (): SettingsProfileLabels => {
+ const tSettings = useTranslations("Settings");
+
+ return {
+ 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 }),
+ };
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_mocks/profile-mock.ts b/apps/timo-web/app/[locale]/(main)/settings/_mocks/profile-mock.ts
new file mode 100644
index 00000000..9226811a
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_mocks/profile-mock.ts
@@ -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", "안녕"],
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts b/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts
new file mode 100644
index 00000000..fe131391
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/_types/profile-type.ts
@@ -0,0 +1,41 @@
+export type SettingsLanguage = "ko" | "en";
+
+export type SettingsDefaultTagKey =
+ | "assignment"
+ | "work"
+ | "exercise"
+ | "dailyLife";
+
+export interface SettingsProfile {
+ name: string;
+ googleEmail: string;
+ isCalendarConnected: boolean;
+ tags: string[];
+}
+
+export interface SettingsProfileFormValues {
+ language: SettingsLanguage;
+ tags: string[];
+}
+
+export interface SettingsTagItem {
+ id: string;
+ label: string;
+ isDefault: boolean;
+}
+
+export interface SettingsProfileLabels {
+ title: string;
+ profileSection: string;
+ calendarSection: string;
+ connect: string;
+ disconnect: string;
+ languageSection: string;
+ languageKorean: string;
+ languageEnglish: string;
+ tagsSection: string;
+ addTag: string;
+ logout: string;
+ save: string;
+ removeTag: (tag: string) => string;
+}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/layout.tsx b/apps/timo-web/app/[locale]/(main)/settings/layout.tsx
index be819660..6be37b87 100644
--- a/apps/timo-web/app/[locale]/(main)/settings/layout.tsx
+++ b/apps/timo-web/app/[locale]/(main)/settings/layout.tsx
@@ -1,4 +1,5 @@
import { SettingsHeaderContainer } from "@/app/[locale]/(main)/settings/_containers/SettingsHeaderContainer";
+import { SettingsNavContainer } from "@/app/[locale]/(main)/settings/_containers/SettingsNavContainer";
export default function SettingsLayout({
children,
@@ -6,9 +7,13 @@ export default function SettingsLayout({
children: React.ReactNode;
}) {
return (
- <>
+
- {children}
- >
+
+
+
+
+
+
);
}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/page.tsx b/apps/timo-web/app/[locale]/(main)/settings/page.tsx
index fe25cb52..f0e8940e 100644
--- a/apps/timo-web/app/[locale]/(main)/settings/page.tsx
+++ b/apps/timo-web/app/[locale]/(main)/settings/page.tsx
@@ -1,3 +1,5 @@
+import { SettingsProfileContainer } from "@/app/[locale]/(main)/settings/_containers/SettingsProfileContainer";
+
export default function SettingsPage() {
- return <>>;
+ return ;
}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/policy/page.tsx b/apps/timo-web/app/[locale]/(main)/settings/policy/page.tsx
index b621d253..2645d493 100644
--- a/apps/timo-web/app/[locale]/(main)/settings/policy/page.tsx
+++ b/apps/timo-web/app/[locale]/(main)/settings/policy/page.tsx
@@ -1,3 +1,8 @@
export default function SettingsPolicyPage() {
- return <>>;
+ return (
+
+
약관
+
+
+ );
}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_components/SettingsWithdrawalView.tsx b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_components/SettingsWithdrawalView.tsx
new file mode 100644
index 00000000..10773d1e
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_components/SettingsWithdrawalView.tsx
@@ -0,0 +1,55 @@
+import { WarningGrayIcon } from "@repo/timo-design-system/icons";
+
+import type { SettingsWithdrawalLabels } from "@/app/[locale]/(main)/settings/withdrawal/_types/withdrawal-type";
+
+export interface SettingsWithdrawalViewProps {
+ labels: SettingsWithdrawalLabels;
+ onWithdraw: () => void;
+}
+
+export const SettingsWithdrawalView = ({
+ labels,
+ onWithdraw,
+}: SettingsWithdrawalViewProps) => {
+ return (
+
+
{labels.title}
+
+
+
+
+
+ {labels.guideTitle}
+
+
+ {labels.guideDescription}
+
+
+
+
+
+ {labels.warningTitle}
+
+
+ {labels.warnings.map((warning) => (
+
+ ))}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_containers/SettingsWithdrawalContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_containers/SettingsWithdrawalContainer.tsx
new file mode 100644
index 00000000..6f449f87
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_containers/SettingsWithdrawalContainer.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+
+import type { SettingsWithdrawalLabels } from "@/app/[locale]/(main)/settings/withdrawal/_types/withdrawal-type";
+
+import { SettingsWithdrawalView } from "@/app/[locale]/(main)/settings/withdrawal/_components/SettingsWithdrawalView";
+
+export const SettingsWithdrawalContainer = () => {
+ const t = useTranslations("Settings.withdrawal");
+
+ const labels: SettingsWithdrawalLabels = {
+ title: t("title"),
+ guideTitle: t("guideTitle"),
+ guideDescription: t("guideDescription"),
+ warningTitle: t("warningTitle"),
+ warnings: [
+ t("warningSchedule"),
+ t("warningCalendar"),
+ t("warningIrreversible"),
+ ],
+ withdraw: t("withdraw"),
+ };
+
+ const handleWithdraw = () => {
+ // TODO: 실제 확인 모달로 교체
+ const confirmed = window.confirm(t("confirmMessage"));
+ if (!confirmed) return;
+
+ // TODO: API - 회원 탈퇴 및 데이터 영구 삭제
+ console.log("회원 탈퇴 API를 호출합니다.");
+ };
+
+ return ;
+};
diff --git a/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_queries/.gitkeep b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_queries/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_types/withdrawal-type.ts b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_types/withdrawal-type.ts
new file mode 100644
index 00000000..4816c7a9
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/_types/withdrawal-type.ts
@@ -0,0 +1,8 @@
+export interface SettingsWithdrawalLabels {
+ title: string;
+ guideTitle: string;
+ guideDescription: string;
+ warningTitle: string;
+ warnings: string[];
+ withdraw: string;
+}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/withdrawal/page.tsx b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/page.tsx
new file mode 100644
index 00000000..3c2559ad
--- /dev/null
+++ b/apps/timo-web/app/[locale]/(main)/settings/withdrawal/page.tsx
@@ -0,0 +1,5 @@
+import { SettingsWithdrawalContainer } from "@/app/[locale]/(main)/settings/withdrawal/_containers/SettingsWithdrawalContainer";
+
+export default function SettingsWithdrawalPage() {
+ return ;
+}
diff --git a/apps/timo-web/constants/routes.ts b/apps/timo-web/constants/routes.ts
index 2f9e6a91..9b3301f2 100644
--- a/apps/timo-web/constants/routes.ts
+++ b/apps/timo-web/constants/routes.ts
@@ -4,4 +4,6 @@ export const ROUTES = {
FOCUS: "/focus",
STATISTICS: "/statistics",
SETTINGS: "/settings",
+ SETTINGS_POLICY: "/settings/policy",
+ SETTINGS_WITHDRAWAL: "/settings/withdrawal",
} as const;
diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json
index e5eef234..bab7fee8 100644
--- a/apps/timo-web/messages/en.json
+++ b/apps/timo-web/messages/en.json
@@ -36,6 +36,39 @@
"viewWeek": "7 days",
"addTask": "Add a task"
},
+ "Settings": {
+ "nav": {
+ "account": "Account",
+ "policy": "Terms & Policies",
+ "withdrawal": "Delete account"
+ },
+ "profile": {
+ "title": "Personal information",
+ "profileSection": "Profile",
+ "calendarSection": "Google Calendar integration",
+ "connect": "Integrate",
+ "disconnect": "Disconnect",
+ "languageSection": "Language",
+ "languageKorean": "Korean",
+ "languageEnglish": "English",
+ "tagsSection": "Tags",
+ "addTag": "Add tag",
+ "removeTag": "Delete {tag} tag",
+ "logout": "Log out",
+ "save": "Save changes"
+ },
+ "withdrawal": {
+ "title": "Withdraw",
+ "guideTitle": "Are you no longer using Timo?",
+ "guideDescription": "After withdrawal, your account information and all saved data will be deleted and cannot be recovered.",
+ "warningTitle": "Please check before withdrawing.",
+ "warningSchedule": "Saved schedules and tasks will be deleted.",
+ "warningCalendar": "The linked calendar connection will be disconnected.",
+ "warningIrreversible": "Deleted accounts cannot be recovered.",
+ "withdraw": "Withdraw",
+ "confirmMessage": "Are you sure you want to withdraw? All your data will be deleted and cannot be undone."
+ }
+ },
"Toast": {
"tagLimit": "You can create up to 8 tags, and to add more, please delete existing tags.",
"todoLimitLine1": "You can add up to 20 incomplete to-dos.",
diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json
index 5549d58e..c987cd57 100644
--- a/apps/timo-web/messages/ko.json
+++ b/apps/timo-web/messages/ko.json
@@ -36,6 +36,39 @@
"viewWeek": "7일",
"addTask": "할 일을 추가하세요"
},
+ "Settings": {
+ "nav": {
+ "account": "개인 정보",
+ "policy": "약관",
+ "withdrawal": "회원 탈퇴"
+ },
+ "profile": {
+ "title": "개인 정보",
+ "profileSection": "프로필",
+ "calendarSection": "구글 캘린더 연동",
+ "connect": "연동하기",
+ "disconnect": "연동 해제",
+ "languageSection": "언어",
+ "languageKorean": "한국어",
+ "languageEnglish": "English",
+ "tagsSection": "태그",
+ "addTag": "태그 추가",
+ "removeTag": "{tag} 태그 삭제",
+ "logout": "로그아웃",
+ "save": "변경사항 저장"
+ },
+ "withdrawal": {
+ "title": "탈퇴",
+ "guideTitle": "더이상 Timo를 이용하지 않으시나요?",
+ "guideDescription": "탈퇴 후에는 계정 정보와 저장된 모든 데이터가 삭제되며 복구할 수 없어요.",
+ "warningTitle": "탈퇴 전 꼭 확인해주세요.",
+ "warningSchedule": "저장된 일정 및 할 일이 삭제됩니다.",
+ "warningCalendar": "연동된 캘린더 연결이 해제됩니다.",
+ "warningIrreversible": "삭제된 계정은 복구할 수 없습니다.",
+ "withdraw": "탈퇴하기",
+ "confirmMessage": "정말 탈퇴하시겠어요? 탈퇴 후에는 모든 데이터가 삭제되며 되돌릴 수 없어요."
+ }
+ },
"Toast": {
"tagLimit": "태그는 최대 8개까지 만들 수 있으며, 추가하려면 기존 태그를 삭제해 주세요.",
"todoLimitLine1": "완료되지 않은 투두는 최대 20개까지 추가할 수 있어요.",
diff --git a/apps/timo-web/package.json b/apps/timo-web/package.json
index 92b8707a..dbc581d3 100644
--- a/apps/timo-web/package.json
+++ b/apps/timo-web/package.json
@@ -23,6 +23,7 @@
"next-intl": "^4.13.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-hook-form": "^7.81.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
diff --git a/apps/timo-web/public/images/google-calendar.png b/apps/timo-web/public/images/google-calendar.png
new file mode 100644
index 00000000..76c7c564
Binary files /dev/null and b/apps/timo-web/public/images/google-calendar.png differ
diff --git a/apps/timo-web/public/images/google-logo.png b/apps/timo-web/public/images/google-logo.png
new file mode 100644
index 00000000..831de1aa
Binary files /dev/null and b/apps/timo-web/public/images/google-logo.png differ
diff --git a/packages/timo-design-system/src/components/button/pill-button/PillButton.stories.tsx b/packages/timo-design-system/src/components/button/pill-button/PillButton.stories.tsx
new file mode 100644
index 00000000..66c7ac7d
--- /dev/null
+++ b/packages/timo-design-system/src/components/button/pill-button/PillButton.stories.tsx
@@ -0,0 +1,42 @@
+import { PillButton } from "./PillButton";
+import { PlusGrayIcon } from "../../../icons";
+
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta = {
+ title: "Components/Button/PillButton",
+ component: PillButton,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["gray", "blue"],
+ },
+ children: {
+ control: "text",
+ },
+ },
+ args: {
+ variant: "gray",
+ children: "로그아웃",
+ onClick: () => {},
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Blue: Story = {
+ args: { variant: "blue", children: "연동하기" },
+};
+
+export const WithIcon: Story = {
+ args: {
+ children: "태그 추가",
+ icon: ,
+ },
+};
diff --git a/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx b/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx
new file mode 100644
index 00000000..73b61259
--- /dev/null
+++ b/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx
@@ -0,0 +1,39 @@
+import { cn } from "../../../lib";
+
+import type { ReactNode } from "react";
+
+export type PillButtonVariant = "gray" | "blue";
+
+const PILL_BUTTON_VARIANT: Record = {
+ gray: "bg-timo-gray-300 text-timo-gray-900",
+ blue: "bg-timo-blue-300 text-white",
+};
+
+export interface PillButtonProps {
+ children: ReactNode;
+ variant?: PillButtonVariant;
+ icon?: ReactNode;
+ onClick: () => void;
+ className?: string;
+}
+
+export const PillButton = ({
+ children,
+ variant = "gray",
+ icon,
+ onClick,
+ className,
+}: PillButtonProps) => {
+ const chipClassName = cn(
+ "typo-body-m-12 flex h-7.5 shrink-0 items-center justify-center gap-1.5 rounded-[4px] px-3",
+ PILL_BUTTON_VARIANT[variant],
+ className,
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/timo-design-system/src/components/index.ts b/packages/timo-design-system/src/components/index.ts
index 65223ba0..cf779e08 100644
--- a/packages/timo-design-system/src/components/index.ts
+++ b/packages/timo-design-system/src/components/index.ts
@@ -36,4 +36,6 @@ export { TodayButton } from "./button/today-button/TodayButton";
export { ChevronButton } from "./button/chevron-button/ChevronButton";
export { TimeSelector } from "./time/time-selector/TimeSelector";
export { RepeatSelector } from "./repeat/repeat-selector/RepeatSelector";
+export { PillButton } from "./button/pill-button/PillButton";
+export { TagChip } from "./tag/tag-chip/TagChip";
export { Calendar } from "./calendar/Calendar";
diff --git a/packages/timo-design-system/src/components/tag/tag-chip/TagChip.stories.tsx b/packages/timo-design-system/src/components/tag/tag-chip/TagChip.stories.tsx
new file mode 100644
index 00000000..cab6868d
--- /dev/null
+++ b/packages/timo-design-system/src/components/tag/tag-chip/TagChip.stories.tsx
@@ -0,0 +1,44 @@
+import { TagChip } from "./TagChip";
+
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta = {
+ title: "Components/Tag/TagChip",
+ component: TagChip,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ children: {
+ control: "text",
+ },
+ },
+ args: {
+ children: "과제",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Removable: Story = {
+ args: {
+ children: "안녕",
+ onRemove: () => {},
+ removeLabel: "안녕 태그 삭제",
+ },
+};
+
+export const AllStates: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => (
+
+ 과제
+ {}} removeLabel="안녕 태그 삭제">
+ 안녕
+
+
+ ),
+};
diff --git a/packages/timo-design-system/src/components/tag/tag-chip/TagChip.tsx b/packages/timo-design-system/src/components/tag/tag-chip/TagChip.tsx
new file mode 100644
index 00000000..f349a503
--- /dev/null
+++ b/packages/timo-design-system/src/components/tag/tag-chip/TagChip.tsx
@@ -0,0 +1,40 @@
+import { DeleteIcon } from "../../../icons";
+import { cn } from "../../../lib";
+
+import type { ReactNode } from "react";
+
+export interface TagChipProps {
+ children: ReactNode;
+ onRemove?: () => void;
+ removeLabel?: string;
+ className?: string;
+}
+
+export const TagChip = ({
+ children,
+ onRemove,
+ removeLabel,
+ className,
+}: TagChipProps) => {
+ const chipClassName = cn(
+ "typo-body-m-12 bg-timo-gray-300 text-timo-gray-900 flex h-7.5 shrink-0 items-center justify-center gap-1.5 rounded-[4px] px-3",
+ className,
+ );
+
+ if (onRemove) {
+ return (
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/timo-design-system/src/icons/source/warning-gray.svg b/packages/timo-design-system/src/icons/source/warning-gray.svg
new file mode 100644
index 00000000..8d87bef4
--- /dev/null
+++ b/packages/timo-design-system/src/icons/source/warning-gray.svg
@@ -0,0 +1,3 @@
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a7e9c27e..2ed8e93f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,6 +68,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.0(react@19.2.0)
+ react-hook-form:
+ specifier: ^7.81.0
+ version: 7.81.0(react@19.2.0)
zod:
specifier: ^4.4.3
version: 4.4.3
@@ -3877,6 +3880,12 @@ packages:
peerDependencies:
react: ^19.2.0
+ react-hook-form@7.81.0:
+ resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -8059,6 +8068,10 @@ snapshots:
react: 19.2.0
scheduler: 0.27.0
+ react-hook-form@7.81.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+
react-is@16.13.1: {}
react-is@17.0.2: {}