Repository navigation
[FEAT] 설정 페이지 개인정보·탈퇴 화면 구현 #127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
0448956
2cfa22d
d22ee51
6dc08a0
f479b60
0637127
4949da1
d66f953
87b91b6
5d1d873
d5b1c4a
a8d06b9
1705563
12fc4b8
7b29f68
649e1ee
5a79e43
7c202f7
178ba4d
58f53e8
3a8816d
a63f37b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
|
|
||
| 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 }), | ||
| }; | ||
|
jjangminii marked this conversation as resolved.
Resolved
|
||
|
|
||
| const handleConnectCalendar = () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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 })); | ||
| }; | ||
|
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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 화면설계서에 "1~10자의 중복 없는 신규 태그명 입력 시 활성화 상태로 클릭 가능" 이라는 조건이 있는데 현재는 중복 검사만 있고 길이 제한 로직이 없는 것 같습니다 확인 부탁드릴게용🙇♀️
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", "안녕"], | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.