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}

+
+ Google + + {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} - + +
+ +
{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) => ( +
+ +

+ {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: {}